Skip to content
UI Kit
Accent color

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

5 variants

Variants

Full window pictures

Pictures fill the window behind cards that scroll past. Each one fades in from a slight zoom when its card arrives, the marks on the right count along.

Open in Studio

Camera over a map

One large map stays, and the camera travels to the place of each chapter, pulling back a little between two stops like a real move.

Open in Studio

Wipe through a day

The pictures of one day replace each other with a wipe from the right, the chapter text rises with each one and the list beside it keeps count.

Open in Studio

Film strip

A portrait frame with a film strip that moves on with every chapter, the picture in front grows to full size. The text changes beside it.

Open in Studio

Browser with tabs

A browser window stays, the screens slide sideways from chapter to chapter. The tabs above fill as you go and jump to their chapter on click.

Open in Studio

Details

What Pinned media brings along, read from the registry and the source.

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

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

  • variantoptional
    PinnedMediaVariant

    fullbleedpan-zoomwipereeldevice

  • eyebrowoptional
    string
  • titleoptional
    React.ReactNode
  • descriptionoptional
    string
  • chaptersrequired
    MediaChapter[]
  • mediaoptional
    React.ReactNode

    pan-zoom: the one large picture the camera travels over

  • urloptional
    string

    device: 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
goToEnglish Go toGerman Gehe zu
chaptersEnglish ChaptersGerman 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
components/ui/pinned-media.tsx
'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.

Often searched

Jump to

Copied to the clipboard