DDI

ScrollRevealX

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.

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

Either panel slot may be omitted — an edge given no content simply has no wrapper in the rendered DOM.

Scroll the middle strip horizontally. The top and bottom panels slide out of view as you scroll right, and back in as you scroll back left — or hover/click them to bring them back.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Top panel — scroll right to hide, left to reveal (or hover/click here)
Bottom panel
---
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} />