ScrollRevealY wraps a single child that starts in normal static flow. Once
the user has scrolled past it and then scrolls back up, the child “escapes”
flow — staying position: fixed for the whole escaped stretch and tracking
the scroll 1:1 via a transform, until it settles flush with the viewport
edge once fully revealed. Scrolling back up further, past the child’s own
original position, returns it to normal flow. Hovering or clicking the child
while it’s still partway revealed commits it fully open immediately.
That describes the default, anchor="top". Set anchor="bottom" for a bar
that belongs at the bottom of the screen. The direction is the same — scroll
down hides, scroll up reveals — but a bottom bar rests somewhere else, and
that changes its whole shape:
- It never sits in normal flow. The place a bottom bar would return to
is the bottom edge it reveals to, so there’s no flow position to go back
to. It rests shown, pinned, from the start;
initial-placementnever applies to it and it starts outattached. - It’s pinned to the bottom of its container’s visible part — the bottom
of the viewport, except where the container ends above it (it slides in and
out of the container’s bottom edge instead, and rides it away) or hasn’t
been reached yet (it waits just inside the container’s top). So it arrives
and leaves with the container it belongs to, and never renders outside it,
instead of floating over the rest of the page. With no
data-ddi-scroll-reveal-y-containerancestor, that container isdocument.body, i.e. the whole page. - Against a container edge it still slides in from below at the same rate as it does against the viewport’s edge. That edge scrolls with the page, so it comes down to meet the child; the reveal keeps pace with it, which means uncovering twice as fast. Without that the two motions cancel out and the child appears to unfold downward rather than slide up.
- Its own position in the flow doesn’t affect its behaviour — only the container’s bounds do. The wrapper still reserves the child’s height where you put it, though, so put it where the bar should leave a gap: usually at the end of the container, so nothing ends up hidden underneath it.
- It stays shown near the top of the page: until the page has scrolled
more than its height plus
reveal-threshold, scrolling down doesn’t hide it — the same margin a top bar gets from sitting in the flow there. - An overscroll bounce (rubber-banding past either end of the page, as on iOS) doesn’t count as scrolling, so it neither hides nor reveals it.
Always use the ScrollRevealY component (@dynamic-type/ddi/server) rather
than the underlying <ddi-scroll-reveal-y> custom element directly — it
provides the display: block the element needs to lay out at all.
Attributes
These are the custom attributes.
anchor—"top"(the default) or"bottom": which edge the child belongs to. See above — this is more than a mirror image.reveal-threshold— extra px of scroll-up required before the child begins to appear at all. Defaults to0, i.e. it starts appearing on the first pixel scrolled back up.data-ddi-scroll-reveal-y-container— goes on the ancestor that bounds the child, found viaclosest("[data-ddi-scroll-reveal-y-container]")fromScrollRevealYitself — so it doesn’t have to be the immediate parent, just an ancestor. Falls back todocument.bodyif omitted. Where the container runs out of room, the child docks to its edge asposition: absoluteat a document coordinate, so it scrolls away with the container rather than floating past it.
The state is exposed as three mutually exclusive boolean attributes on the
element, for styling (ddi-scroll-reveal-y[attached] .bar { … }):
initial-placement— the child is in normal static flow, not yet escaped. Top-anchored bars only.attaching— the child has escaped flow and is partially revealed.attached— the child is fully revealed, flush with the anchored edge.
Finally:
inactivated— a boolean attribute that pins the element atinitial-placement, even once scrolled past. Removing it resyncs from the current scroll position.held— a boolean attribute that keeps the child fully revealed: a user scroll never hides it while present. Use it while the reader is working inside the child, e.g. toggled on a field’sfocusin/focusout. A top-anchored child pins to the top edge on the first scroll down, instead of scrolling away with its slot, and returns to the flow once the slot is back in view. Setting it reveals a hidden or partly hidden child, animated like a hover. Removing it changes nothing until the next scroll down hides the child as usual.inactivatedwins when both are set.
Programmatic scrolling
ScrollRevealY treats scrolling as user intent, so an app that moves the page
itself (repositioning content, scroll-into-view, restoring a saved position)
would make the child reveal or hide when it shouldn’t. Wrap those scrolls in
doProgrammaticScroll (from @dynamic-type/ddi/client) and the element ignores
exactly that scroll while still reacting to real user scrolls:
import { doProgrammaticScroll } from "@dynamic-type/ddi/client";
doProgrammaticScroll(() => window.scrollTo(0, targetY));
doProgrammaticScroll(() => selectedRow.scrollIntoView());
Only instant scrolls are covered — pass a function that performs the scroll
synchronously (no behavior: "smooth").
A programmatic scroll never hides the child. If a top-anchored child is still
in its place when the app scrolls it out of view (a deep link, a restored
position), it settles fully revealed, so the page lands with its header
showing; only the user’s own scroll down hides it after that. Any follow-up
scroll the app makes itself must go through doProgrammaticScroll too, or it
counts as the user’s and slides the header back out.
---
import { ScrollRevealY, Style, css } from "@dynamic-type/ddi/server";
const [{ pane, container }, rules] = css`
.container {
u: height:200vh width:90cqw p:4xs mbe:150vh bg;
}
.pane {
u: width:100% height:100px bg:rgb(240,200,0);
}
[initial-placement] .pane {
u: b:light;
}
[attaching] .pane {
u: drop-shadow bg:rgba(240,200,0,0.5)
}
[attached] .pane {
u: drop-shadow bg:rgba(240,200,0,0.75);
}
`;
---
<div class:list={[container, "box"]} data-ddi-scroll-reveal-y-container>
<ScrollRevealY>
<div class:list={pane}></div>
</ScrollRevealY>
</div>
<Style rules={rules} />---
import { ScrollRevealY, Style, css } from "@dynamic-type/ddi/server";
const [{ pane, container }, rules] = css`
/* The bar never occupies its slot in the flow, so put the slot where the
gap belongs: at the end of the container. */
.container {
u: height:200vh width:90cqw p:4xs mbe:150vh bg;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.pane {
u: width:100% height:100px bg:rgb(0,180,200);
}
[initial-placement] .pane {
u: b:light;
}
[attaching] .pane {
u: drop-shadow bg:rgba(0,180,200,0.5)
}
[attached] .pane {
u: drop-shadow bg:rgba(0,180,200,0.75);
}
`;
---
<div class:list={[container, "box"]} data-ddi-scroll-reveal-y-container>
<ScrollRevealY anchor="bottom">
<div class:list={pane}></div>
</ScrollRevealY>
</div>
<Style rules={rules} />---
import { ScrollRevealY, Style, css } from "@dynamic-type/ddi/server";
const [{ pane, container }, rules] = css`
.container {
u: height:200vh width:90cqw p:4xs mbe:150vh bg;
}
.pane {
u: width:100% height:100px p:4xs bg:rgb(120,200,80);
display: flex;
align-items: center;
}
[initial-placement] .pane {
u: b:light;
}
[attaching] .pane {
u: drop-shadow bg:rgba(120,200,80,0.5)
}
[attached] .pane {
u: drop-shadow bg:rgba(120,200,80,0.75);
}
/* Shows when the field is holding the bar. */
[held] .pane {
outline: 3px dashed currentColor;
outline-offset: -3px;
}
`;
---
<div class:list={[container, "box"]} data-ddi-scroll-reveal-y-container>
<ScrollRevealY>
<div class:list={pane}>
<input type="search" placeholder="Focus me, then scroll" data-holds-reveal />
</div>
</ScrollRevealY>
</div>
<Style rules={rules} />
<script>
// Hold the bar while the field has focus, so it can't hide under the
// reader's fingers.
for (const field of document.querySelectorAll<HTMLElement>(
"[data-holds-reveal]",
)) {
const reveal = field.closest("ddi-scroll-reveal-y");
field.addEventListener("focusin", () =>
reveal?.setAttribute("held", ""),
);
field.addEventListener("focusout", () =>
reveal?.removeAttribute("held"),
);
}
</script>