Skip to content
peetzweg/ui

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:

peetzweg/uicredit
•••• 4242
peetzweg/uicredit
•••• 1881
peetzweg/uicredit
•••• 0573
peetzweg/uicredit
•••• 9921
1.586 / 1 — horizontal
peetzweg/uicredit
•••• 4242
peetzweg/uicredit
•••• 1881
peetzweg/uicredit
•••• 0573
peetzweg/uicredit
•••• 9921
1 / 1.586 — vertical
1 / 4
const 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-machine

Depends 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.

1 / 7

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.)

peetzweg/uicredit
•••• 4242
RECEIPT
Coffee4.50
Croissant3.20
Tip1.00
TOTAL8.70
★ thank you ★
peetzweg/uicredit
•••• 0573
peetzweg/uicredit
•••• 9921
1 / 4

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-44
h-40 w-56
h-48 w-72

Props

PropTypeDefaultDescription
itemsReact.ReactNode[]Frames to stack. Each is fully styled by you.
driver"scroll" | "keyboard" | "controlled""scroll"How the active frame advances.
activeIndexnumberControlled index.
defaultIndexnumber0Initial index when uncontrolled.
onIndexChange(index: number) => voidFires when the active frame changes.
framesVisiblenumber3Depth of the receding stack.
frameOffsetnumber-30Vertical px offset per depth step.
scaleStepnumber0.08Scale removed per depth step.
snapDistancenumber50Wheel px required to advance one frame (scroll).
loopbooleanfalseWrap around at the ends.
blurnumber2Blur (px) on passed frames; 0 disables.
spring{ stiffness?; damping?; mass? }{ stiffness: 250, damping: 20, mass: 0.5 }Frame transition spring.
frameClassNamestringExtra classes on every frame wrapper (e.g. one size).
classNamestringClasses on the root (set the overall size here).