Rotate showcase on scroll
An object that turns with the scroll and shows a new side or page at every turn, with the matching chapter beside it. Screens change while the back faces you, so the change is never seen.
A new screen at every turn
A rotate showcase turns one object with the scroll and shows a new side or page at every turn, with the matching chapter beside it. The screens change while the back faces you, so the change itself stays out of sight and the object feels solid.
Phone that turns spins a phone once per screen and shows its back with the cameras in between, the chapters on the left jump there on a click. Laptop from back to front turns a laptop round, opens its lid and runs through its screens one chapter at a time. Cube of pages puts four pages on the sides of a cube, a quarter turn per page, good for the pages of a website or the areas of an app. Spinning card turns a card in your own design, for memberships, gift and payment cards.
Three to five screens make a clear tour. Show the screen people use most first. Keep the chapter text beside the object short, the turn carries the attention.
Variants
Details
What Rotate showcase brings along, read from the registry and the source.
- Category
- 3D scenes
- Area
- Visuals
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/rotate-showcase.tsx
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- In collections
- 3D and motion
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The RotateShowcaseProps interface from components/ui/rotate-showcase.tsx, with the comments from the code. Also takes every prop of Omit<React.HTMLAttributes<HTMLElement>, 'title'>.
variantoptionalRotateShowcaseVariantphonelaptopcubecard
eyebrowoptionalstringtitlerequiredReact.ReactNodedescriptionoptionalstringchaptersrequiredRotateChapter[]One chapter per screen (phone, laptop, cube) or per side of the card
screensoptionalReact.ReactNode[]One screen per chapter (phone, laptop, cube)
viewportoptionalnumberRender the screens at this CSS width and scale them, default 390 for the phone and 1280 otherwise
cardoptional{ name: string; number?: string; label?: string; valid?: string }card: the printing on the card
actionoptional{ label: string; href: string }
Accessibility
Read from the source of the component.
- Sets aria-current and aria-live, so screen readers announce purpose and state.
- Announces changes through a live region without moving focus.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 324 lines
'use client';
import { useCallback, useRef, useState } from 'react';
import { cn, round } from '@/lib/kit';
import { Eyebrow } from '@/components/ui/section';
import { Button } from '@/components/ui/button';
import { Reveal } from '@/components/ui/reveal';
import { MockupScreen } from '@/components/ui/mockup';
import { DeviceBody } from '@/components/ui/device-3d';
import { scrollToProgress, smooth, span, useLive, useSceneProgress } from '@/components/ui/scroll-scene';
/*
* An object that turns with the scroll and shows a new side, a new page, at every turn. The section
* stays in place while the object turns, the chapter beside it changes with the side in front.
* Screens change while the object shows its back or its edge, so the change itself is never seen.
* Only transforms are written, onto one element per frame.
*
* Without JavaScript and with reduced motion the object stands still with its first page, and the
* chapters stand as a list beside it, every screen shown in a row below.
*
* phone a phone turns round once per screen and shows its back in between
* laptop a laptop turns from its back to the front, opens its lid and runs through its screens
* cube four pages on the sides of a cube, a quarter turn per page
* card a card spins, front and back take turns with the features beside it
*/
export type RotateShowcaseVariant = 'phone' | 'laptop' | 'cube' | 'card';
export interface RotateChapter {
title: string;
text?: string;
meta?: string;
}
export interface RotateShowcaseProps extends Omit<React.HTMLAttributes<HTMLElement>, 'title'> {
variant?: RotateShowcaseVariant;
eyebrow?: string;
title: React.ReactNode;
description?: string;
/** One chapter per screen (phone, laptop, cube) or per side of the card */
chapters: RotateChapter[];
/** One screen per chapter (phone, laptop, cube) */
screens?: React.ReactNode[];
/** Render the screens at this CSS width and scale them, default 390 for the phone and 1280 otherwise */
viewport?: number;
/** card: the printing on the card */
card?: { name: string; number?: string; label?: string; valid?: string };
action?: { label: string; href: string };
}
function Chapters({ chapters, active, onPick }: { chapters: RotateChapter[]; active: number; onPick?: (i: number) => void }) {
return (
<ol className="mt-8 grid gap-1.5">
{chapters.map((c, i) => {
const on = i === active;
// active -1 is the still layout (no JavaScript, reduced motion): every chapter shows its text
const inner = (
<>
<span className={cn('mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-md text-[12px] font-semibold tabular-nums transition-colors duration-500', on ? 'bg-primary text-primary-foreground' : 'bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] text-muted-foreground')}>{i + 1}</span>
<span className="min-w-0">
<span className={cn('block text-[15.5px] font-semibold transition-colors duration-500', on ? 'text-foreground' : 'text-muted-foreground')}>{c.title}</span>
{c.text && <span className={cn('grid transition-[grid-template-rows,opacity] duration-500', on || active < 0 ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0')}><span className="overflow-hidden text-[14px] leading-relaxed text-muted-foreground">{c.text}</span></span>}
</span>
</>
);
return (
<li key={i}>
{onPick ? (
<button type="button" onClick={() => onPick(i)} aria-current={on ? 'step' : undefined} className={cn('flex min-h-11 w-full cursor-pointer items-start gap-3 rounded-xl p-3 text-left transition-colors duration-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-primary', on ? 'bg-card shadow-[var(--highlight),var(--shadow-sm)]' : 'hover:bg-accent')}>
{inner}
</button>
) : (
<div className="flex items-start gap-3 p-3">{inner}</div>
)}
</li>
);
})}
</ol>
);
}
/** A drawn card of our own design, front or back */
function CardFace({ card, back = false }: { card?: RotateShowcaseProps['card']; back?: boolean }) {
if (back)
return (
<div className="relative size-full overflow-hidden rounded-[6%/9.5%] bg-[color-mix(in_oklab,var(--primary)_70%,black)] text-white shadow-[var(--shadow-lg)]">
<span className="absolute inset-x-0 top-[14%] h-[17%] bg-[#0b0d12]" />
<span className="absolute left-[7%] top-[42%] flex h-[13%] w-[62%] items-center justify-end rounded-sm bg-[repeating-linear-gradient(135deg,#f3f1ea_0_6px,#e6e2d6_6px_12px)] pr-[3%] text-[clamp(9px,1.6cqw,13px)] font-semibold italic text-[#1a1d24]">
{card?.name}
</span>
<span className="absolute left-[72%] top-[42%] flex h-[13%] w-[14%] items-center justify-center rounded-sm bg-white text-[clamp(9px,1.6cqw,13px)] font-semibold text-[#1a1d24]">123</span>
<span className="absolute bottom-[10%] left-[7%] text-[clamp(8px,1.3cqw,11px)] opacity-70">{card?.label}</span>
</div>
);
return (
<div className="relative size-full overflow-hidden rounded-[6%/9.5%] bg-primary text-primary-foreground shadow-[var(--shadow-lg)]">
<span className="absolute inset-0 bg-[radial-gradient(120%_90%_at_0%_0%,color-mix(in_oklab,white_28%,transparent),transparent_55%),radial-gradient(90%_80%_at_100%_100%,color-mix(in_oklab,black_30%,transparent),transparent_60%)]" />
<span className="absolute left-[7%] top-[11%] text-[clamp(11px,2.2cqw,18px)] font-semibold tracking-[-0.01em]">{card?.label}</span>
{/* chip */}
<span className="absolute left-[7%] top-[36%] h-[19%] w-[13%] rounded-[18%] bg-[linear-gradient(135deg,#e9d7a6,#b8995a)] shadow-[inset_0_0_0_1px_rgb(0_0_0/0.15)]">
<span className="absolute inset-x-[18%] top-1/2 h-px bg-[rgb(0_0_0/0.25)]" />
<span className="absolute inset-y-[18%] left-1/2 w-px bg-[rgb(0_0_0/0.25)]" />
</span>
<span className="absolute bottom-[26%] left-[7%] text-[clamp(11px,2.4cqw,20px)] font-medium tracking-[0.14em] tabular-nums">{card?.number}</span>
<span className="absolute bottom-[10%] left-[7%] text-[clamp(9px,1.6cqw,13px)] font-medium uppercase tracking-[0.08em] opacity-90">{card?.name}</span>
<span className="absolute bottom-[10%] right-[7%] text-[clamp(9px,1.6cqw,13px)] tabular-nums opacity-90">{card?.valid}</span>
{/* two rings as a neutral mark */}
<span className="absolute right-[7%] top-[10%] flex">
<span className="size-[clamp(14px,3.6cqw,30px)] rounded-full border-2 border-current opacity-80" />
<span className="-ml-[clamp(6px,1.4cqw,12px)] size-[clamp(14px,3.6cqw,30px)] rounded-full border-2 border-current opacity-50" />
</span>
</div>
);
}
/** Screens hide while the device shows its back, so nothing behind it counts as seen */
function face(el: HTMLElement, front: boolean) {
el.querySelectorAll<HTMLElement>('[data-clip-ok]').forEach((s) => {
s.style.visibility = front ? '' : 'hidden';
});
}
/* ------------------------------------------------------------------ */
/** An object that turns with the scroll and shows a new page at every turn. */
export function RotateShowcase({ variant = 'phone', eyebrow, title, description, chapters, screens = [], viewport, card, action, className, onFocus, ...rest }: RotateShowcaseProps) {
const live = useLive();
const obj = useRef<HTMLDivElement | null>(null);
const lid = useRef<HTMLDivElement | null>(null);
const n = Math.max(1, chapters.length);
const [active, setActive] = useState(0);
const [screen, setScreen] = useState(0);
const st = useRef({ a: 0, s: 0 });
const vp = viewport ?? (variant === 'phone' ? 390 : 1280);
const scr = (i: number) => (screens.length ? screens[i % screens.length] : null);
const apply = useCallback(
(p: number) => {
const el = obj.current;
if (!el) return;
const q = smooth(span(p, 0.04, 0.96));
let a = 0;
let s = 0;
if (variant === 'phone') {
// one full turn per change of screen, a small resting lean at both ends
const turns = q * (n - 1);
const deg = turns * 360;
el.style.transform = `rotateX(6deg) rotateY(${round(-18 + deg, 2)}deg)`;
face(el, Math.cos(((-18 + deg) * Math.PI) / 180) > 0);
a = Math.round(turns);
s = Math.floor(turns + 0.5);
} else if (variant === 'laptop') {
const turn = smooth(span(p, 0.02, 0.3));
const ly = 180 - turn * 200 + 20 * smooth(span(p, 0.3, 0.42));
el.style.transform = `rotateX(-14deg) rotateY(${round(ly, 2)}deg)`;
face(el, Math.cos((ly * Math.PI) / 180) > 0);
const open = smooth(span(p, 0.18, 0.36));
lid.current?.style.setProperty('--lid-angle', `${round(-88 + open * 96, 2)}deg`);
const rest = span(p, 0.4, 0.98) * (n - 1);
a = Math.round(rest);
s = a;
} else if (variant === 'cube') {
const turns = q * (n - 1);
const cy = -turns * 90 - 14;
el.style.transform = `rotateX(-8deg) rotateY(${round(cy, 2)}deg)`;
el.querySelectorAll<HTMLElement>('[data-face]').forEach((f) => {
f.style.visibility = Math.cos(((cy + Number(f.dataset.face) * 90) * Math.PI) / 180) > 0.02 ? '' : 'hidden';
});
a = Math.round(turns);
s = a;
} else {
const turns = q * (n - 1);
const ry = -16 + q * Math.max(1, Math.ceil((n - 1) / 2)) * 360;
el.style.transform = `rotateX(${round(14 - Math.sin(q * Math.PI) * 8, 2)}deg) rotateY(${round(ry, 2)}deg) rotateZ(${round(-6 + q * 6, 2)}deg)`;
const front = Math.cos((ry * Math.PI) / 180) > 0;
el.querySelectorAll<HTMLElement>('[data-face]').forEach((f) => {
f.style.visibility = (f.dataset.face === '0') === front ? '' : 'hidden';
});
a = Math.round(turns);
}
if (a !== st.current.a) {
st.current.a = a;
setActive(Math.min(n - 1, a));
}
if (s !== st.current.s) {
st.current.s = s;
setScreen(Math.min(n - 1, s));
}
},
[variant, n],
);
const track = useSceneProgress<HTMLDivElement>(live, { self: false, onProgress: apply });
const pick = (i: number) => {
const per = 0.92 / Math.max(1, n - 1);
const at = variant === 'laptop' ? 0.4 + (i / Math.max(1, n - 1)) * 0.56 : 0.04 + i * per;
scrollToProgress(track.current, at);
};
const actionEl = action && (
<div className="mt-8 max-sm:[&>span]:w-full">
<Button href={action.href} icon="arrow-right" magnetic className="max-sm:w-full">
{action.label}
</Button>
</div>
);
/* ---------- the object ---------- */
const object = (cur: number) => {
if (variant === 'phone')
return (
<div className="mx-auto w-[min(56vw,16rem)] [container-type:inline-size] [perspective:1400px] sm:w-[17rem]">
<div ref={obj} className="[transform-style:preserve-3d]" style={{ transform: 'rotateX(6deg) rotateY(-18deg)' }}>
<DeviceBody device="phone" viewport={vp}>
{scr(cur)}
</DeviceBody>
</div>
</div>
);
if (variant === 'laptop')
return (
<div className="mx-auto w-[min(86vw,40rem)] pb-[18%] [container-type:inline-size] [perspective:1600px]">
<div ref={obj} className="[transform-style:preserve-3d]" style={{ transform: 'rotateX(-14deg) rotateY(0deg)' }}>
<div ref={lid} className="[transform-style:preserve-3d] [--lid-duration:0ms]">
<DeviceBody device="laptop" viewport={vp}>
{scr(cur)}
</DeviceBody>
</div>
</div>
</div>
);
if (variant === 'cube') {
// faces: front, right, back, left; the cube is as deep as it is wide
return (
<div className="mx-auto w-[min(58vw,30rem)] [container-type:inline-size] [perspective:1600px]">
<div ref={obj} className="relative aspect-[16/11] [transform-style:preserve-3d]" style={{ transform: 'rotateX(-8deg) rotateY(-14deg)' }}>
{[0, 1, 2, 3].map((f) => (
<div
key={f}
data-face={f}
className="absolute inset-0 overflow-hidden rounded-xl border border-border bg-background shadow-[var(--shadow-md)] [backface-visibility:hidden]"
style={{ transform: `rotateY(${f * 90}deg) translateZ(50cqw)`, visibility: f === 0 ? undefined : 'hidden' }}
>
<div className="flex h-7 items-center gap-1.5 border-b border-border bg-surface px-3" aria-hidden>
{[0, 1, 2].map((d) => (
<span key={d} className="size-2 rounded-full bg-[color-mix(in_oklab,var(--foreground)_16%,var(--surface))]" />
))}
</div>
<div className="relative h-[calc(100%-1.75rem)]">
<MockupScreen viewport={vp} guess={480}>
{f < n ? scr(f) : null}
</MockupScreen>
</div>
</div>
))}
</div>
</div>
);
}
return (
<div className="mx-auto w-[min(78vw,26rem)] [container-type:inline-size] [perspective:1400px]">
<div ref={obj} className="relative aspect-[1.586] [transform-style:preserve-3d]" style={{ transform: 'rotateX(14deg) rotateY(-16deg) rotateZ(-6deg)' }}>
<div data-face="0" className="absolute inset-0 [backface-visibility:hidden]">
<CardFace card={card} />
</div>
<div data-face="1" className="absolute inset-0 [backface-visibility:hidden]" style={{ transform: 'rotateY(180deg)', visibility: 'hidden' }}>
<CardFace card={card} back />
</div>
</div>
</div>
);
};
const head = (
<>
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<h2 className="text-balance text-h2 font-semibold text-foreground">{title}</h2>
{description && <p className="mt-4 max-w-[30rem] text-pretty text-lead text-muted-foreground">{description}</p>}
</>
);
if (!live) {
return (
<section {...rest} onFocus={onFocus} className={cn('relative overflow-x-clip py-20 sm:py-28', className)}>
<div className="kit-container grid grid-cols-1 items-center gap-12 md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] lg:gap-16">
<Reveal>
{head}
<Chapters chapters={chapters} active={-1} />
{actionEl}
</Reveal>
<div className="py-8 [perspective:1400px]">{object(0)}</div>
</div>
</section>
);
}
return (
<section {...rest} onFocus={onFocus} className={cn('relative overflow-x-clip', className)}>
<div ref={track} className="relative" style={{ height: `${round((n * 0.9 + 1) * 100, 1)}svh` }}>
<div data-clip-ok className="sticky top-0 h-svh overflow-clip">
<div className="kit-container grid h-full grid-cols-1 grid-rows-[auto_minmax(0,1fr)] items-center gap-4 py-6 md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] md:grid-rows-1 md:gap-16 md:py-0">
<div className="relative z-10">
<div className="max-md:hidden">
{head}
<Chapters chapters={chapters} active={active} onPick={pick} />
{actionEl}
</div>
{/* phones: the heading and the chapter in front, the object below */}
<div className="md:hidden">
<h2 className="text-balance text-[1.6rem] font-semibold leading-tight text-foreground">{title}</h2>
<p aria-live="polite" className="mt-3 text-[15px] text-muted-foreground">
<span className="font-semibold text-foreground">{chapters[active]?.title}</span>
{chapters[active]?.text && <span className="block">{chapters[active]?.text}</span>}
</p>
</div>
</div>
<div className="relative flex h-full items-center justify-center [perspective:1400px]">{object(screen)}</div>
</div>
</div>
</div>
{actionEl && <div className="kit-container flex justify-center pb-16 md:hidden">{actionEl}</div>}
</section>
);
}Frequent questions
How do I install Rotate showcase?
With aiw add @ai-website/rotate-showcase. The command puts components/ui/rotate-showcase.tsx into your project, the code is yours from then on. It brings along section, button, reveal, mockup, device-3d and scroll-scene. It needs no further npm packages.
Which variants does Rotate showcase have?
4 variants, Phone that turns, Laptop from back to front, Cube of pages and Spinning card. You pick one with the variant prop, for example variant="laptop".
Can an AI assistant build with Rotate showcase?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Rotate showcase with aiw_components and install it with aiw_add @ai-website/rotate-showcase. Name the variant in your prompt, for example variant="phone", and the assistant writes your content straight into the props.