Stehende Medien
Ein Medium, das stehen bleibt, während die Kapitel wechseln, und jeder Wechsel ist ein sichtbarer Übergang. Bilder blenden mit Tiefe über, eine Kamera fährt über eine Karte, Bilder wischen, ein Filmstreifen läuft, Bildschirme gleiten im Browser.
Eine von 4 Components in Scroll-Geschichten
Variants
Details
Was Stehende Medien mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Scroll-Geschichten
- Bereich
- Marketing
- Variants
- 5
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/pinned-media.tsx
- Bringt mit
- utilskitrevealsectionframescroll-scene
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 2 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle PinnedMediaProps aus components/ui/pinned-media.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von Omit<React.HTMLAttributes<HTMLElement>, 'title'> an.
variantoptionalPinnedMediaVariantfullbleedpan-zoomwipereeldevice
eyebrowoptionalstringtitleoptionalReact.ReactNodedescriptionoptionalstringchaptersPflichtMediaChapter[]mediaoptionalReact.ReactNodepan-zoom: the one large picture the camera travels over
urloptionalstringdevice: address shown in the browser window
Eingebaute Texte
Diese Texte schreibt Stehende Medien selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 2 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| goTo | Englisch Go to | Deutsch Gehe zu |
| chapters | Englisch Chapters | Deutsch Kapitel |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-current, aria-label und aria-live, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rolle group.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 465 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich Stehende Medien?
Mit aiw add @ai-website/pinned-media. Der Befehl legt die Datei components/ui/pinned-media.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind reveal, section, frame und scroll-scene. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Stehende Medien?
5 Varianten, Bilder im ganzen Fenster, Kamera über einer Karte, Wischen durch den Tag, Filmstreifen und Browser mit Reitern. Du wählst sie mit der Prop variant, zum Beispiel variant="pan-zoom".
Kann ich die Texte von Stehende Medien übersetzen?
Ja. Stehende Medien bringt 2 eigene Texte auf Deutsch und Englisch mit, etwa „Gehe zu“ und „Kapitel“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Stehende Medien einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Stehende Medien mit aiw_components und installieren sie mit aiw_add @ai-website/pinned-media. Nenn im Prompt die Variante, etwa variant="fullbleed", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Stehende Medien ohne Code anpassen?
Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden PinnedMedia-Code zum Kopieren.