TimeMachine
A stack of frames you cycle and scroll through, with a 3D-recede animation: frames recede with a spring as the active index advances, and passed frames blur and fade out. It owns only the stack — the cycling, scrolling, and recede motion. You own each frame entirely: size, aspect ratio, border, background, and contents (an image, a card, a link, an auto-playing slideshow — any React node).
Because items is just ReactNode[], the frame shape and style are yours —
here the same stack rendered as horizontal and vertical credit cards (the
ISO 1.586 ratio), each a gradient card the component never styles:
1.586 / 1 — horizontal1 / 1.586 — verticalconst cards = accounts.map((a) => (
<a href={a.href} className="aspect-[1.586/1] w-80 rounded-2xl …">
{/* your border, gradient, chip, even a nested slideshow */}
</a>
))
<TimeMachine items={cards} className="h-[240px] w-[360px]" />Installation
pnpm dlx shadcn@latest add peetzweg/ui/time-machineDepends only on motion.
Usage
import { TimeMachine } from "@/components/ui/time-machine"
const frames = photos.map((src) => (
<div key={src} className="h-48 w-72 overflow-hidden rounded-xl border">
<img src={src} alt="" className="h-full w-full object-cover" />
</div>
))
<TimeMachine items={frames} className="h-[400px] w-full" />Controlled
The interactive form: a controlled stack you drive with Prev / Next. In
scroll mode the component scrubs on wheel within its own bounds; arrow keys
work in scroll and keyboard modes.
Scroll
Set driver="scroll" and the stack scrubs on wheel within its own bounds — no
page-scroll hijacking. It's uncontrolled here, so the component owns the index;
by default it clamps at the first and last frame:
Scroll over it to scrub — 1 / 8, clamping at the ends
<TimeMachine items={frames} driver="scroll" />Looping
Add loop and the same scroll stack wraps instead of clamping — go past the
last frame and it returns to the first, with no dead ends. The scroll driver
also takes arrow keys, so ← / → step one frame at a time (handy for precise
navigation) and wrap the same way:
Scroll or use ← / → — 1 / 4, wrapping past either end
<TimeMachine items={frames} driver="scroll" loop />Mixed shapes & sizes
Frames don't have to match. Each item sizes itself and is centered in the
stack, so you can mix shapes freely — here a taller, narrower receipt sits
among vertical credit cards. (Just don't pin a uniform size via
frameClassName when you want them to differ.)
Uniform sizing
If every frame should share one size/style, set it once via frameClassName
instead of on each item — the recede math is size-independent, so the same
component works at any scale:
h-32 w-44h-40 w-56h-48 w-72Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | React.ReactNode[] | — | Frames to stack. Each is fully styled by you. |
driver | "scroll" | "keyboard" | "controlled" | "scroll" | How the active frame advances. |
activeIndex | number | — | Controlled index. |
defaultIndex | number | 0 | Initial index when uncontrolled. |
onIndexChange | (index: number) => void | — | Fires when the active frame changes. |
framesVisible | number | 3 | Depth of the receding stack. |
frameOffset | number | -30 | Vertical px offset per depth step. |
scaleStep | number | 0.08 | Scale removed per depth step. |
snapDistance | number | 50 | Wheel px required to advance one frame (scroll). |
loop | boolean | false | Wrap around at the ends. |
blur | number | 2 | Blur (px) on passed frames; 0 disables. |
spring | { stiffness?; damping?; mass? } | { stiffness: 250, damping: 20, mass: 0.5 } | Frame transition spring. |
frameClassName | string | — | Extra classes on every frame wrapper (e.g. one size). |
className | string | — | Classes on the root (set the overall size here). |