ScrollStrip
A horizontal filmstrip of clipped slivers. Every frame is a full-size box
clipped down to a thin sliver via clip-path: inset() — expanding a frame
just animates the clip open while its neighbors part by exactly the revealed
amount, so there's no layout thrash.
Scroll to browse (native momentum, collapses the strip), settle to snap to — and expand — the nearest frame. Click toggles a frame, arrow keys step while expanded, Escape collapses. Scoped to its own container: no page-scroll hijacking.
The clip only reveals — what's painted underneath is entirely up to the
frame, so collapsed and expanded can be different layouts, not just a crop.
Here the collapsed layer is a book spine (a vertical title sized to the sliver,
readable without expanding) and the expanded layer is the full cover, cross-faded
on active. With frameHeight === expandedHeight the spines stand on a shelf
and the cover opens purely sideways:
function Book({ book, active }: { book: Book; active: boolean }) {
return (
<div className="relative h-full w-full">
{/* spine — readable in the collapsed sliver */}
<div className={cn("absolute inset-0 grid place-items-center transition-opacity",
active ? "opacity-0" : "opacity-100")}>
<span className="[writing-mode:vertical-rl] uppercase tracking-widest">
{book.title}
</span>
</div>
{/* cover — full layout, revealed as the clip opens */}
<div className={cn("absolute inset-0 p-5 transition-opacity",
active ? "opacity-100" : "opacity-0")}>
…
</div>
</div>
)
}Installation
pnpm dlx shadcn@latest add peetzweg/ui/scroll-stripPulls the use-scroll-end
hook and clamp
lib item (both registryDependencies), plus motion.
Usage
import { ScrollStrip } from "@/components/ui/scroll-strip"
<ScrollStrip
items={images.map((src) => (
<img key={src} src={src} className="h-full w-full object-cover" />
))}
frameWidth={48}
expandedWidth={256}
frameHeight={192}
expandedHeight={360}
frameRadius={10}
frameClassName="grayscale data-[active=true]:grayscale-0 transition-[filter]"
/>Frames are headless: each item fills the expanded box (expandedWidth wide)
and shows through the sliver clip while collapsed — so content should cover
the box, e.g. an object-cover image. Style state via the
data-active="true" | "false" attribute on each frame wrapper.
Frames as components
Items aren't limited to images — any React component works, and with the
render-function form an item receives its live { active, index } state, so
content can restructure itself in and out of focus. Here each collapsed
sliver is a credit card seen edge-on — standing in a wallet, vertical brand
name readable — and the proper card face (chip, number, holder) only fades
in once active:
<ScrollStrip
items={cards.map((card) => ({ active }) => (
<CreditCard card={card} active={active} />
))}
frameWidth={17}
expandedWidth={224}
frameHeight={220}
expandedHeight={354}
frameRadius={{ collapsed: 6, expanded: 16 }}
/>Frame geometry stays prop-driven — the clip reveal and neighbor parting
need a fixed expanded box to compute against — but everything inside the box
is yours to animate. CSS transitions keyed off the active prop (or the
data-active attribute) sync content changes with the clip opening.
Plain images
The simplest case is the one in Usage above: plain images, each filling the expanded box and seen through the sliver while collapsed. Scroll to browse; the strip settles and expands the nearest frame.
Vertical
The mechanic is axis-symmetric: with orientation="vertical" frames clip to
horizontal slivers in a stack, scrolling is vertical, and ↑/↓ step while
expanded. Here's the shelf rotated — each collapsed sliver is a book lying
flat (spine facing up, title readable), and expanding pulls the full cover
down out of the stack. frameWidth === expandedWidth, so the cover is
revealed purely downwards:
<ScrollStrip
orientation="vertical"
items={books.map((book) => ({ active }) => (
<Book book={book} active={active} />
))}
frameWidth={300}
expandedWidth={300}
frameHeight={40}
expandedHeight={240}
frameRadius={6}
style={{ height: 440 }}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | ScrollStripItem[] | — | Frame contents — nodes, or ({ active, index }) => ReactNode render functions. Each fills the expanded box. |
orientation | "horizontal" | "vertical" | "horizontal" | Scroll axis; slivers clip along it. |
frameWidth | number | 72 | Visible sliver width when collapsed (px). |
frameHeight | number | 288 | Collapsed frame height (px). |
expandedWidth | number | 480 | Expanded frame width (px). |
expandedHeight | number | 720 | Expanded frame height; also the strip's height (px). |
gap | number | 16 | Gap between slivers (px). |
frameRadius | number | { collapsed?; expanded? } | 0 | Corner radius applied through the clip-path (px); per-state values animate with the reveal. |
activeIndex | number | null | — | Controlled expanded index; null = collapsed. |
defaultActiveIndex | number | null | null | Initial expanded index when uncontrolled. |
onActiveIndexChange | (index: number | null) => void | — | Notified on expand / collapse / step. |
expandOnSettle | boolean | true | Auto-expand the nearest frame when scrolling settles. |
spring | { stiffness?; damping?; mass? } | 500 / 50 | Spring for the expand / part / clip animations. |
frameClassName | string | — | Extra classes on each frame wrapper. |
Plus all div props — className / style land on the scroll container.