Pinned media
A medium that stays while the chapters change, and every change is a visible transition. Pictures fade with depth, a camera travels over a map, pictures wipe, a film strip runs on and screens slide in a browser.
One of 4 components in Scroll stories
Variants
Details
What Pinned media brings along, read from the registry and the source.
- Category
- Scroll stories
- Area
- Marketing
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/pinned-media.tsx
- Brings along
- utilskitrevealsectionframescroll-scene
- npm packages
- None
- Languages
- English and German, 2 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The PinnedMediaProps interface from components/ui/pinned-media.tsx, with the comments from the code. Also takes every prop of Omit<React.HTMLAttributes<HTMLElement>, 'title'>.
variantoptionalPinnedMediaVariantfullbleedpan-zoomwipereeldevice
eyebrowoptionalstringtitleoptionalReact.ReactNodedescriptionoptionalstringchaptersrequiredMediaChapter[]mediaoptionalReact.ReactNodepan-zoom: the one large picture the camera travels over
urloptionalstringdevice: address shown in the browser window
Built-in texts
Pinned media writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 2 texts
| Key | English | German |
|---|---|---|
| goTo | English Go to | German Gehe zu |
| chapters | English Chapters | German Kapitel |
Accessibility
Read from the source of the component.
- Sets aria-current, aria-label and aria-live, so screen readers announce purpose and state.
- Uses the role group.
- 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, 465 lines
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { Eyebrow } from '@/components/ui/section';
import { BrowserFrame } from '@/components/ui/frame';
import { Reveal } from '@/components/ui/reveal';
import { lerp, smooth, span, useLive, useSceneProgress } from '@/components/ui/scroll-scene';
/*
* A medium that stays while the chapters change. The picture is the stage, the chapters are what
* the reader scrolls through, and every change of chapter is a visible transition of the medium:
* a camera move, a wipe, a film strip, a cross fade with depth, a slide between screens.
*
* Without JavaScript and with reduced motion every chapter stands with its own picture, one after
* another, so nothing is missing.
*
* fullbleed pictures fill the window behind cards that scroll past, they fade into each other
* pan-zoom one large map or plan, the camera travels to the place of each chapter
* wipe pictures of one day replace each other with a wipe, the chapter list keeps count
* reel a portrait frame with a film strip that moves on with every chapter
* device a browser window with tabs above it, the screens slide sideways from tab to tab
*/
export type PinnedMediaVariant = 'fullbleed' | 'pan-zoom' | 'wipe' | 'reel' | 'device';
export interface MediaChapter {
title: string;
text?: string;
/** Small line above the title: a time, a place, a step */
eyebrow?: string;
/** The picture of this chapter (not used by pan-zoom) */
media?: React.ReactNode;
/** pan-zoom: where the camera looks, 0 to 1 across and down, and how close (1 is the whole picture) */
focus?: { x: number; y: number; zoom?: number };
}
export interface PinnedMediaProps extends Omit<React.HTMLAttributes<HTMLElement>, 'title'> {
variant?: PinnedMediaVariant;
eyebrow?: string;
title?: React.ReactNode;
description?: string;
chapters: MediaChapter[];
/** pan-zoom: the one large picture the camera travels over */
media?: React.ReactNode;
/** device: address shown in the browser window */
url?: string;
labels?: Partial<(typeof TEXTS)['en']>;
}
const TEXTS = {
en: { goTo: 'Go to', chapters: 'Chapters' },
de: { goTo: 'Gehe zu', chapters: 'Kapitel' },
};
const pad = (n: number) => String(n).padStart(2, '0');
type Els = Record<string, HTMLElement | null>;
/** Stage size, and for the scenes with scrolling cards the track height, window height and heading height */
interface Box {
w: number;
h: number;
track: number;
vh: number;
head: number;
}
const tf = (el: HTMLElement | null | undefined, t: string) => {
if (el) el.style.transform = t;
};
const op = (el: HTMLElement | null | undefined, o: number) => {
if (el) el.style.opacity = String(round(o, 3));
};
/** Weight of chapter i at the chapter position c: full near it, a soft fade in between */
const weight = (c: number, i: number, hold = 0.28) => Math.min(1, Math.max(0, 1 - (Math.abs(c - i) - hold) / (1 - 2 * hold)));
/** What every variant does with the chapter position c (0 … n-1) */
function applyScene(variant: PinnedMediaVariant, c: number, n: number, e: Els, chapters: MediaChapter[], box: Box) {
if (variant === 'fullbleed') {
for (let i = 0; i < n; i++) {
const w = smooth(weight(c, i));
op(e[`m${i}`], w);
tf(e[`m${i}`], `scale(${round(lerp(1.08, 1, w), 4)})`);
}
}
if (variant === 'pan-zoom') {
const a = Math.floor(c);
const b = Math.min(n - 1, a + 1);
const k = smooth(span(c - a, 0.15, 0.85));
const fa = chapters[a]?.focus ?? { x: 0.5, y: 0.5, zoom: 1 };
const fb = chapters[b]?.focus ?? fa;
const x = lerp(fa.x, fb.x, k);
const y = lerp(fa.y, fb.y, k);
// The camera pulls back a little between two places, like a real move
const z = lerp(fa.zoom ?? 1.6, fb.zoom ?? 1.6, k) * (1 - 0.18 * Math.sin(Math.PI * k));
// The place stands in the middle, on phones in the upper third, above the card at the bottom
const ty = box.w < 768 ? 0.32 : 0.5;
tf(e.map, `translate3d(${round((0.5 - x) * box.w * z, 1)}px, ${round((ty - 0.5) * box.h - (y - 0.5) * box.h * z, 1)}px, 0) scale(${round(z, 4)})`);
for (let i = 0; i < n; i++) op(e[`pin${i}`], lerp(0.45, 1, weight(c, i, 0.2)));
}
if (variant === 'wipe') {
// Picture i is uncovered from the right while c runs from i - 1 to i: the frame moves in, the picture moves against it
for (let i = 1; i < n; i++) {
const k = smooth(span(c, i - 0.75, i - 0.15));
tf(e[`w${i}`], `translate3d(${round((1 - k) * 100, 3)}%, 0, 0)`);
tf(e[`wi${i}`], `translate3d(${round(-(1 - k) * 100, 3)}%, 0, 0)`);
}
tf(e.bar, `scaleY(${round(n > 1 ? c / (n - 1) : 1, 4)})`);
}
if (variant === 'reel') {
tf(e.strip, `translate3d(0, ${round(-c * (box.h + 16), 1)}px, 0)`);
for (let i = 0; i < n; i++) tf(e[`r${i}`], `scale(${round(lerp(0.9, 1, smooth(weight(c, i, 0.15))), 4)})`);
tf(e.bar, `scaleX(${round(n > 1 ? c / (n - 1) : 1, 4)})`);
}
if (variant === 'device') {
const a = Math.floor(c);
const k = smooth(span(c - a, 0.3, 0.7));
tf(e.screens, `translate3d(${round(-(a + k) * 100, 3)}%, 0, 0)`);
// A tab fills while its screen comes in, the first one is full from the start
for (let i = 0; i < n; i++) tf(e[`seg${i}`], `scaleX(${round(i === 0 ? 1 : span(c, i - 1, i), 4)})`);
}
}
/* ------------------------------------------------------------------ */
function Head({ eyebrow, title, description, center, className }: { eyebrow?: string; title?: React.ReactNode; description?: string; center?: boolean; className?: string }) {
if (!title && !eyebrow) return null;
return (
<div className={cn('max-w-[42rem]', center && 'mx-auto text-center', className)}>
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
{title && <h2 className="text-balance text-h2 font-semibold text-foreground">{title}</h2>}
{description && <p className={cn('mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground', center && 'mx-auto')}>{description}</p>}
</div>
);
}
function ChapterText({ ch, i, n, className, big }: { ch: MediaChapter; i: number; n: number; className?: string; big?: boolean }) {
return (
<div className={className}>
<p className="flex items-center gap-3 text-[13px] font-medium tabular-nums text-muted-foreground">
<span className="text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">{pad(i + 1)}</span>
<span aria-hidden className="h-px w-8 bg-border-strong" />
<span>{ch.eyebrow ?? `${pad(i + 1)} / ${pad(n)}`}</span>
</p>
<h3 className={cn('mt-4 text-balance font-semibold tracking-[-0.02em] text-foreground', big ? 'text-[clamp(1.75rem,1.2rem+1.8vw,2.75rem)] leading-[1.08]' : 'text-[clamp(1.375rem,1.1rem+0.8vw,1.75rem)] leading-tight')}>{ch.title}</h3>
{ch.text && <p className="mt-3 max-w-[30rem] text-pretty text-[15.5px] leading-relaxed text-muted-foreground">{ch.text}</p>}
</div>
);
}
/** Pins on the pan-zoom picture, one per chapter */
function Pins({ chapters, set, active }: { chapters: MediaChapter[]; set?: (k: string) => (n: HTMLElement | null) => void; active?: number }) {
return (
<>
{chapters.map((ch, i) =>
ch.focus ? (
<span key={i} ref={set?.(`pin${i}`)} aria-hidden className="absolute -translate-x-1/2 -translate-y-full" style={{ left: `${round(ch.focus.x * 100, 2)}%`, top: `${round(ch.focus.y * 100, 2)}%` }}>
<span className={cn('relative flex items-center gap-1.5 rounded-full bg-primary py-1 pl-1 pr-2.5 text-[11px] font-semibold text-primary-foreground shadow-[var(--shadow-md)]', active === i && 'ring-4 ring-[color-mix(in_oklab,var(--primary)_30%,transparent)]')}>
<span className="flex size-5 items-center justify-center rounded-full bg-[color-mix(in_oklab,var(--primary-foreground)_22%,transparent)] tabular-nums">{i + 1}</span>
<span className="max-w-[9rem] truncate">{ch.eyebrow ?? ch.title}</span>
</span>
<span className="mx-auto block h-2 w-px bg-primary" />
</span>
) : null,
)}
</>
);
}
/* ------------------------------------------------------------------ */
/** A medium that stays while the chapters change, five kinds of transition. */
export function PinnedMedia({ variant = 'fullbleed', eyebrow, title, description, chapters, media, url, labels, className, ...rest }: PinnedMediaProps) {
const t = useTexts(TEXTS, labels);
const live = useLive();
const n = chapters.length;
const els = useRef<Els>({});
const box = useRef<Box>({ w: 0, h: 0, track: 0, vh: 0, head: 0 });
const last = useRef(0);
const [active, setActive] = useState(0);
const set = (k: string) => (node: HTMLElement | null) => {
els.current[k] = node;
};
const apply = useCallback(
(p: number) => {
last.current = p;
const b = box.current;
// Scenes with cards: chapter i is in front while its card crosses the middle of the window
const cards = variant === 'fullbleed' || variant === 'pan-zoom';
const c = Math.min(n - 1, Math.max(0, cards && b.vh ? (p * (b.track - b.vh) - b.head) / b.vh : p * (n - 1)));
setActive(Math.min(n - 1, Math.max(0, Math.round(c))));
applyScene(variant, c, n, els.current, chapters, box.current);
},
[variant, n, chapters],
);
const track = useSceneProgress<HTMLDivElement>(live, { self: false, onProgress: apply });
useEffect(() => {
if (!live) return;
const target = els.current.box;
const measure = () => {
if (target) box.current = { w: target.clientWidth, h: target.clientHeight, track: track.current?.offsetHeight ?? 0, vh: window.innerHeight, head: els.current.head?.offsetHeight ?? 0 };
apply(last.current);
};
measure();
if (!target) return;
const ro = new ResizeObserver(measure);
ro.observe(target);
return () => ro.disconnect();
}, [live, apply]);
/** device tabs: scroll the page to the place where a chapter is in front */
const goTo = (i: number) => {
const el = track.current;
if (!el) return;
const top = el.getBoundingClientRect().top + window.scrollY;
const travel = el.offsetHeight - window.innerHeight;
window.scrollTo({ top: Math.round(top + (n > 1 ? i / (n - 1) : 0) * travel), behavior: 'smooth' });
};
/* ---------- Finished layout: no JavaScript, reduced motion, server ---------- */
if (!live) {
return (
<section {...rest} className={cn('relative overflow-x-clip py-20 sm:py-24 lg:py-32', className)}>
<div className="kit-container">
<Reveal>
<Head eyebrow={eyebrow} title={title} description={description} />
</Reveal>
{variant === 'pan-zoom' ? (
<div className="mt-12 grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)] lg:gap-14">
<div className="relative aspect-[4/3] overflow-hidden rounded-2xl border border-border bg-surface [&>.m>*]:size-full">
<div className="m absolute inset-0">{media}</div>
<Pins chapters={chapters} />
</div>
<ol className="grid content-start gap-8">
{chapters.map((ch, i) => (
<li key={i}>
<ChapterText ch={ch} i={i} n={n} />
</li>
))}
</ol>
</div>
) : (
<ol className="mt-12 grid grid-cols-1 gap-12 sm:mt-16 lg:gap-16">
{chapters.map((ch, i) => (
<li key={i} className={cn('grid grid-cols-1 items-center gap-6 md:grid-cols-2 md:gap-12', i % 2 && 'md:[&>*:first-child]:order-2')}>
{variant === 'device' ? (
// The browser window keeps its own shape, the screen inside it is 16 by 10 as in the scene
<BrowserFrame url={url}>
<div className="aspect-[16/10] overflow-hidden [&>*]:size-full">{ch.media}</div>
</BrowserFrame>
) : (
<div className={cn('relative overflow-hidden rounded-2xl bg-surface [&>*]:size-full', variant === 'reel' ? 'aspect-[4/5]' : 'aspect-[4/3]')}>{ch.media}</div>
)}
<ChapterText ch={ch} i={i} n={n} big />
</li>
))}
</ol>
)}
</div>
</section>
);
}
/* ---------- Live scenes ---------- */
const screens = Math.max(2.5, n * 0.9);
if (variant === 'fullbleed') {
return (
<section {...rest} className={cn('relative overflow-x-clip', className)}>
<div ref={track} className="relative">
<div ref={set('box')} className="dark sticky top-0 h-svh overflow-clip bg-bezel">
{chapters.map((ch, i) => (
<div key={i} ref={set(`m${i}`)} data-clip-ok className="absolute inset-0 will-change-transform [&>*]:size-full" style={{ opacity: i === 0 ? 1 : 0 }}>
{ch.media}
</div>
))}
<span className="absolute inset-0 bg-[linear-gradient(to_right,color-mix(in_oklab,var(--background)_70%,transparent),transparent_60%)] max-md:bg-[linear-gradient(to_top,color-mix(in_oklab,var(--background)_80%,transparent),transparent_70%)]" />
<div className="absolute right-6 top-1/2 hidden -translate-y-1/2 flex-col gap-2 md:flex" aria-hidden>
{chapters.map((_, i) => (
<span key={i} className={cn('h-8 w-1 rounded-full transition-colors duration-500', i === active ? 'bg-primary' : 'bg-[color-mix(in_oklab,var(--foreground)_25%,transparent)]')} />
))}
</div>
</div>
{/* The cards scroll over the pictures, one screen per chapter */}
<div className="relative -mt-[100svh]">
{(title || eyebrow) && (
<div ref={set('head')} className="dark flex min-h-[70svh] items-center">
<div className="kit-container">
<Head eyebrow={eyebrow} title={title} description={description} className="rounded-2xl bg-[color-mix(in_oklab,var(--background)_80%,transparent)] p-6 sm:p-8" />
</div>
</div>
)}
{chapters.map((ch, i) => (
<div key={i} className="flex min-h-svh items-end pb-[12svh] md:items-center md:pb-0">
<div className="kit-container">
{/* No backdrop blur here: it would be computed again on every frame of the cross fade below */}
<div className="max-w-[28rem] rounded-2xl border border-border bg-[color-mix(in_oklab,var(--card)_94%,transparent)] p-6 shadow-[var(--shadow-lg)] sm:p-8">
<ChapterText ch={ch} i={i} n={n} big />
</div>
</div>
</div>
))}
</div>
</div>
</section>
);
}
if (variant === 'pan-zoom') {
return (
<section {...rest} className={cn('relative overflow-x-clip', className)}>
<div ref={track} className="relative">
<div ref={set('box')} data-clip-ok className="sticky top-0 h-svh overflow-clip bg-surface">
<div ref={set('map')} className="absolute inset-0 origin-center will-change-transform">
<div className="absolute inset-0 [&>*]:size-full">{media}</div>
<Pins chapters={chapters} set={set} active={active} />
</div>
<span className="pointer-events-none absolute inset-0 bg-[linear-gradient(to_right,color-mix(in_oklab,var(--background)_55%,transparent),transparent_45%)] max-md:bg-[linear-gradient(to_top,color-mix(in_oklab,var(--background)_70%,transparent),transparent_55%)]" />
<p className="absolute bottom-5 right-6 rounded-full bg-[color-mix(in_oklab,var(--background)_80%,transparent)] px-3 py-1.5 text-[12.5px] font-medium tabular-nums text-foreground backdrop-blur-md">
{pad(active + 1)} / {pad(n)}
</p>
</div>
<div className="relative -mt-[100svh]">
{(title || eyebrow) && (
<div ref={set('head')} className="flex min-h-[70svh] items-center">
<div className="kit-container">
<Head eyebrow={eyebrow} title={title} description={description} className="max-w-[30rem] rounded-2xl border border-border bg-[color-mix(in_oklab,var(--card)_88%,transparent)] p-6 shadow-[var(--shadow-lg)] backdrop-blur-md sm:p-8" />
</div>
</div>
)}
{chapters.map((ch, i) => (
<div key={i} className="flex min-h-svh items-end pb-[10svh] md:items-center md:pb-0">
<div className="kit-container">
<div className="max-w-[24rem] rounded-2xl border border-border bg-[color-mix(in_oklab,var(--card)_90%,transparent)] p-6 shadow-[var(--shadow-lg)] backdrop-blur-md">
<ChapterText ch={ch} i={i} n={n} />
</div>
</div>
</div>
))}
</div>
</div>
</section>
);
}
return (
<section {...rest} className={cn('relative overflow-x-clip', className)}>
<div ref={track} className="relative" style={{ height: `${round(screens * 100, 2)}svh` }}>
<div className="sticky top-0 flex h-svh flex-col justify-center overflow-clip py-8 sm:py-12">
<div className="kit-container w-full">
{variant === 'wipe' && (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_17rem] lg:gap-10">
{/* Phones and tablets: the heading stands above the picture, on wide screens it moves into the side column */}
<Head eyebrow={eyebrow} title={title} className="lg:hidden [&_h2]:text-[clamp(1.375rem,1.1rem+1vw,1.75rem)]" />
<div ref={set('box')} data-clip-ok className="relative h-[min(36rem,52svh)] overflow-hidden rounded-2xl bg-surface lg:h-[min(40rem,74svh)]">
{chapters.map((ch, i) => (
<div key={i} ref={set(`w${i}`)} className="absolute inset-0 overflow-hidden will-change-transform" style={i ? { transform: 'translate3d(100%, 0, 0)' } : undefined}>
<div ref={set(`wi${i}`)} className="absolute inset-0 will-change-transform [&>*]:size-full" style={i ? { transform: 'translate3d(-100%, 0, 0)' } : undefined}>
{ch.media}
</div>
</div>
))}
<span className="dark pointer-events-none absolute inset-0 bg-[linear-gradient(to_top,color-mix(in_oklab,var(--background)_90%,transparent),color-mix(in_oklab,var(--background)_70%,transparent)_40%,transparent_75%)]" />
<div className="dark absolute inset-x-0 bottom-0 p-6 sm:p-8">
<div key={active} className="kit-rise">
<ChapterText ch={chapters[active]} i={active} n={n} big />
</div>
</div>
</div>
<div className="flex flex-col justify-between gap-6">
<Head eyebrow={eyebrow} title={title} description={description} className="hidden lg:block [&_h2]:text-[1.75rem] [&_p]:text-[15px]" />
<ol className="relative grid grid-cols-4 gap-2 lg:grid-cols-1 lg:gap-0 lg:pl-5">
<span aria-hidden className="absolute bottom-3 left-0 top-3 hidden w-px bg-border lg:block" />
<span ref={set('bar')} aria-hidden className="absolute bottom-3 left-0 top-3 hidden w-px origin-top bg-primary lg:block" />
{chapters.map((ch, i) => (
<li key={i} aria-current={i === active ? 'step' : undefined} className={cn('border-t-2 pt-2 text-[12.5px] transition-colors duration-500 lg:border-t-0 lg:py-2.5 lg:pt-2.5 lg:text-[14px]', i === active ? 'border-primary font-medium text-foreground' : 'border-border text-muted-foreground')}>
<span className="block truncate">{ch.eyebrow ?? ch.title}</span>
</li>
))}
</ol>
</div>
</div>
)}
{variant === 'reel' && (
<div className="grid grid-cols-1 items-center gap-6 md:grid-cols-[minmax(0,1fr)_minmax(0,0.85fr)] md:gap-14 lg:gap-20">
<div className="order-2 md:order-1">
<Head eyebrow={eyebrow} title={title} className="mb-8 hidden md:block" />
<div className="relative min-h-[11rem] md:min-h-[14rem]">
<div key={active} className="kit-rise">
<ChapterText ch={chapters[active]} i={active} n={n} big />
</div>
</div>
<div className="mt-6 flex items-center gap-4">
<span className="text-[12.5px] font-medium tabular-nums text-muted-foreground">
{pad(active + 1)} / {pad(n)}
</span>
<span className="relative h-0.5 w-40 overflow-hidden rounded-full bg-border">
<span ref={set('bar')} className="absolute inset-0 origin-left rounded-full bg-primary" />
</span>
</div>
</div>
<div className="order-1 mx-auto w-full max-w-[min(26rem,40svh)] md:order-2 md:max-w-[min(30rem,62svh)]">
{/* Phones: the heading in short above the reel, the full one beside it from 768 px */}
{title && <h2 className="mb-4 text-center text-[15px] font-semibold text-foreground md:hidden">{title}</h2>}
<div ref={set('box')} className="relative aspect-[4/5] overflow-hidden rounded-2xl bg-surface shadow-[var(--shadow-lg)]">
<div ref={set('strip')} className="absolute inset-x-0 top-0 will-change-transform">
{chapters.map((ch, i) => (
<div key={i} ref={set(`r${i}`)} className="mb-4 aspect-[4/5] overflow-hidden rounded-2xl [&>*]:size-full">
{ch.media}
</div>
))}
</div>
</div>
</div>
</div>
)}
{variant === 'device' && (
<div className="mx-auto max-w-[60rem]">
{title && <h2 className="mb-6 text-balance text-center text-[clamp(1.375rem,1.1rem+0.9vw,1.875rem)] font-semibold text-foreground">{title}</h2>}
<div role="group" aria-label={t.chapters} className="mb-5 grid gap-2" style={{ gridTemplateColumns: `repeat(${n}, minmax(0, 1fr))` }}>
{chapters.map((ch, i) => (
<button
key={i}
type="button"
aria-current={i === active ? 'step' : undefined}
aria-label={`${t.goTo} ${ch.title}`}
onClick={() => goTo(i)}
className="group min-h-11 rounded-md text-left focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-primary"
>
<span className="relative block h-1 overflow-hidden rounded-full bg-border">
<span ref={set(`seg${i}`)} className="absolute inset-0 origin-left rounded-full bg-primary" style={{ transform: 'scaleX(0)' }} />
</span>
<span className={cn('mt-2.5 block truncate text-[13px] transition-colors duration-500 group-hover:text-foreground', i === active ? 'font-medium text-foreground' : 'text-muted-foreground')}>{ch.eyebrow ?? ch.title}</span>
</button>
))}
</div>
<div className="mx-auto max-w-[min(60rem,88svh)]">
<BrowserFrame url={url}>
<div ref={set('box')} className="relative aspect-[16/10] overflow-hidden">
<div ref={set('screens')} className="absolute inset-0 flex will-change-transform">
{chapters.map((ch, i) => (
<div key={i} className="h-full w-full shrink-0 [&>*]:size-full" aria-hidden={i !== active}>
{ch.media}
</div>
))}
</div>
</div>
</BrowserFrame>
</div>
<div className="mt-6 min-h-[5.5rem] text-center" aria-live="polite">
<div key={active} className="kit-rise mx-auto max-w-[36rem]">
<h3 className="text-[19px] font-semibold text-foreground">{chapters[active].title}</h3>
{chapters[active].text && <p className="mt-2 text-pretty text-[15px] leading-relaxed text-muted-foreground">{chapters[active].text}</p>}
</div>
</div>
</div>
)}
</div>
</div>
</div>
</section>
);
}Frequent questions
How do I install Pinned media?
With aiw add @ai-website/pinned-media. The command puts components/ui/pinned-media.tsx into your project, the code is yours from then on. It brings along reveal, section, frame and scroll-scene. It needs no further npm packages.
Which variants does Pinned media have?
5 variants, Full window pictures, Camera over a map, Wipe through a day, Film strip and Browser with tabs. You pick one with the variant prop, for example variant="pan-zoom".
Can I translate the texts of Pinned media?
Yes. Pinned media carries 2 texts of its own in English and German, such as “Go to” and “Chapters”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Pinned media?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Pinned media with aiw_components and install it with aiw_add @ai-website/pinned-media. Name the variant in your prompt, for example variant="fullbleed", and the assistant writes your content straight into the props.
Can I adjust Pinned media without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching PinnedMedia code to copy.