Skip to content
peetzweg/ui

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-strip

Pulls 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

PropTypeDefaultDescription
itemsScrollStripItem[]Frame contents — nodes, or ({ active, index }) => ReactNode render functions. Each fills the expanded box.
orientation"horizontal" | "vertical""horizontal"Scroll axis; slivers clip along it.
frameWidthnumber72Visible sliver width when collapsed (px).
frameHeightnumber288Collapsed frame height (px).
expandedWidthnumber480Expanded frame width (px).
expandedHeightnumber720Expanded frame height; also the strip's height (px).
gapnumber16Gap between slivers (px).
frameRadiusnumber | { collapsed?; expanded? }0Corner radius applied through the clip-path (px); per-state values animate with the reveal.
activeIndexnumber | nullControlled expanded index; null = collapsed.
defaultActiveIndexnumber | nullnullInitial expanded index when uncontrolled.
onActiveIndexChange(index: number | null) => voidNotified on expand / collapse / step.
expandOnSettlebooleantrueAuto-expand the nearest frame when scrolling settles.
spring{ stiffness?; damping?; mass? }500 / 50Spring for the expand / part / clip animations.
frameClassNamestringExtra classes on each frame wrapper.

Plus all div props — className / style land on the scroll container.