ScrollRevealX owns up to two named-slot panels — reveal-x-top and/or
reveal-x-bottom — that overlay a horizontally-scrolling child, sliding
in and out of view along their own edge. Scrolling that child forward
(right) by more than 25px snaps both panels closed; scrolling it back (left)
past the same threshold snaps them open again. Small back-and-forth jitter
under that threshold never flips it. Both panels always hide/reveal
together — they’re one connected unit (e.g. a reader’s header and footer),
not two independent components.
There’s no partial 1:1 dragging — it’s a binary snap, animated by a CSS
transition on transform. Each panel has two nested elements: an outer
wrapper, positioned in plain CSS to sit flush with the shared parent’s own
edge (position: absolute; top/bottom/left: 0/width: 100%), and an
inner element, holding the actual content, that gets the sliding
transform, fully off-screen (translateY(±100%)) when concealed. The
outer wrapper never changes size or moves on its own, so the content it
overlays is never asked to make room for it.
The tracked scrolling element is a plain child of ScrollRevealX — no
slot name, no marker attribute, just whatever content you nest inside it
alongside the panels. This works even though the panels themselves are
positioned absolute/fixed overlays, because ScrollRevealX renders as
display: contents: it has no box of its own, so it’s transparent to
containing-block resolution — a panel’s position: absolute still resolves
against the nearest real positioned ancestor further up the tree, exactly
as if the tracked element were a sibling rather than a child. Because of
this, that ancestor must itself be position: relative (or otherwise
positioned) for the panels’ absolute pin to anchor and clip correctly —
exactly like any other absolutely positioned overlay.
The tracked child isn’t just looked up once — ScrollRevealX keeps watching
for it to appear, be swapped for a different node, or be removed, and
reconnects scroll tracking accordingly. This matters if that content is
generated client-side by some other script after the page has already
loaded. Content changing inside that same node (e.g. items being added to
a scrolling track) doesn’t trigger anything — only the tracked node’s own
identity changing does.
In the one case where no such child is present at all, there’s no shared
positioned ancestor to rely on, so ScrollRevealX falls back to tracking
document.body’s scroll (window.scrollX) and pins the panels with
position: fixed against the real viewport instead — reflected as the
boolean data-reveal-x-fixed host attribute it sets whenever this happens.
Since each panel’s outer wrapper stays in place the whole time, hovering or
clicking it works like any other element, even while its content is fully
slid away — no separate hit-zone tracking is needed. (This is also why the
hover/click listeners live on each outer wrapper rather than on the host:
the host itself is display: contents — it has no box, and
mouseenter/mouseleave don’t bubble, so a listener on the host would
never fire from a real pointer.) Hovering either panel temporarily reveals
both together (they’re driven by one instance, so this needs no event of
any kind). Clicking commits both open persistently, until the next forward
scroll past the threshold hides them again.
Always use the ScrollRevealX component (@dynamic-type/ddi/server) rather
than the underlying <ddi-scroll-reveal-x> custom element directly — it
provides the panel wrappers, the default slot, and their CSS. The tracked
child is found structurally, as the one direct child that isn’t one of the
panel wrappers — there’s no separate content marker — except in the
document.body fallback case, where the real scroll position is read from
window.scrollX instead.
Attributes
These are the custom attributes.
data-reveal-x-fixed— set automatically (not meant to be set by hand) whenever no tracked child is currently present. Switches the panels fromposition: absolute(relative to the shared ancestor) toposition: fixedagainst the real viewport.revealed/concealed— mutually exclusive, reflect whether the panels are currently shown or slid out of view (nothidden, to avoid colliding with the standard HTML global attribute of that name).inactivated— a boolean attribute that’s a true no-op: it pins the panelsrevealedand stops reacting to scroll, and both wrappers fall back to plain static rendering (the CSS that positions them is itself scoped to:not([inactivated])). Removing it resumes tracking, recalibrated from the current scroll position (not replaying whatever happened while inactivated).
Programmatic scrolling
ScrollRevealX treats scrolling of the tracked child as user intent, so an app
that scrolls it itself (jumping to a card, restoring a position) would flip the
panels when it shouldn’t. Wrap those scrolls in doProgrammaticScroll (from
@dynamic-type/ddi/client) and the panels ignore exactly that scroll while
still reacting to real user scrolls:
import { doProgrammaticScroll } from "@dynamic-type/ddi/client";
doProgrammaticScroll(() => track.scrollTo({ left: targetX }));
doProgrammaticScroll(() => card.scrollIntoView());
Only instant scrolls are covered — pass a function that performs the scroll
synchronously (no behavior: "smooth").
Slots
- Default (unnamed) — the horizontally-scrolling content being tracked.
Omitting it entirely is valid — see the
document.bodyfallback above. reveal-x-top— panel pinned to the top edge, sliding up out of view.reveal-x-bottom— panel pinned to the bottom edge, sliding down out of view.
Either panel slot may be omitted — an edge given no content simply has no wrapper in the rendered DOM.
---
import { ScrollRevealX, scopedRules, Style } from "@dynamic-type/ddi/server";
import { rule, u } from "@dynamic-type/ddi/server";
const CARDS = Array.from({ length: 16 }, (_, i) => ({
label: i + 1,
bg: `hsl(${i * 22.5}, 70%, 55%)`,
}));
const [{ demo, pane, scrollContent, track, card }, rules] = scopedRules([
rule(".demo", {
position: "relative",
overflow: "hidden",
height: "260px",
width: "90cqw",
border: "1px solid rgb(200,200,200)",
}),
rule(".pane", {
...u("d:flex items:center justify:center glass bg:w*30%"),
height: "48px",
"font-size": "0.8rem",
"text-align": "center",
padding: "0 1em",
}),
rule(".scroll-content", {
position: "absolute",
inset: "0",
"overflow-x": "auto",
"overflow-y": "hidden",
}),
rule(".track", {
...u("d:flex"),
height: "100%",
width: "fit-content",
}),
rule(".card", {
...u("d:flex items:center justify:center"),
flex: "none",
width: "150px",
height: "100%",
color: "white",
"font-size": "1.5rem",
}),
]);
---
<div class:list={demo}>
<ScrollRevealX>
<div class:list={scrollContent}>
<div class:list={track}>
{
CARDS.map(({ label, bg }) => (
<div class:list={card} style={{ background: bg }}>
{label}
</div>
))
}
</div>
</div>
<div class:list={pane} slot="reveal-x-top">
Top panel — scroll right to hide, left to reveal (or hover/click
here)
</div>
<div class:list={pane} slot="reveal-x-bottom">Bottom panel</div>
</ScrollRevealX>
</div>
<Style rules={rules} />