Zum Inhalt springen
UI Kit
Accent

Dreh-Showcase beim Scrollen

Ein Objekt, das sich mit dem Scroll dreht und bei jeder Drehung eine neue Seite zeigt, mit dem passenden Kapitel daneben. Die Bildschirme wechseln, während die Rückseite zu sehen ist, den Wechsel selbst sieht man nie.

4 VariantsEine von 6 Components in 3D-Szenen

Bei jeder Drehung ein neuer Bildschirm

Ein Dreh-Showcase dreht ein Objekt mit dem Scroll und zeigt bei jeder Drehung eine neue Seite, mit dem passenden Kapitel daneben. Die Bildschirme wechseln, während die Rückseite zu sehen ist, den Wechsel selbst sieht man nie, und das Objekt wirkt massiv.

„Telefon, das sich dreht“ dreht ein Telefon einmal pro Bildschirm und zeigt dazwischen die Rückseite mit den Kameras, die Kapitel links springen per Klick dorthin. „Laptop von hinten nach vorn“ dreht einen Laptop um, klappt den Deckel auf und zeigt seine Bildschirme Kapitel für Kapitel. „Würfel aus Seiten“ legt vier Seiten auf einen Würfel, eine Vierteldrehung je Seite, gut für die Seiten einer Website oder die Bereiche einer App. Die „Drehende Karte“ dreht eine Karte im eigenen Design, für Mitgliedschaften, Gutschein- und Zahlungskarten.

Drei bis fünf Bildschirme ergeben eine klare Tour. Zeig zuerst den Bildschirm, den Menschen am meisten nutzen. Halte den Kapiteltext kurz, die Drehung trägt die Aufmerksamkeit.

Variants

Telefon, das sich drehtNeu in 1.2

Ein Telefon dreht sich je Bildschirm einmal herum und zeigt dazwischen seine Rückseite mit den Kameras. Die Kapitel links lassen sich anklicken.

Laptop von hinten nach vornNeu in 1.2

Ein Laptop dreht sich von hinten nach vorn und öffnet den Deckel, danach läuft er Kapitel für Kapitel durch seine Bildschirme.

Würfel aus SeitenNeu in 1.2

Vier Seiten auf den Flächen eines Würfels, eine Vierteldrehung pro Seite. Gut, um die Seiten einer Website oder die Bereiche einer App zu zeigen.

Drehende KarteNeu in 1.2

Eine Karte in eigenem Design dreht sich mit dem Scroll, Vorder- und Rückseite wechseln sich ab, während die Vorteile daneben wechseln. Für Mitgliedschaften, Geschenk- und Zahlkarten.

Details

Was Dreh-Showcase mitbringt, aus Registry und Quellcode gelesen.

Kategorie
3D-Szenen
Bereich
Visuals
Variants
4
Version
1.0.0 · seit 1.2.0
Datei
components/ui/rotate-showcase.tsx
Bringt mit
utilskitsectionbuttonrevealmockupdevice-3dscroll-scene
npm-Pakete
Keine
Sprachen
Texte kommen über Props
Studio
Im Code anpassen
In Sammlungen
3D und Bewegung

Props

Die Schnittstelle RotateShowcaseProps aus components/ui/rotate-showcase.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von Omit<React.HTMLAttributes<HTMLElement>, 'title'> an.

  • variantoptional
    RotateShowcaseVariant

    phonelaptopcubecard

  • eyebrowoptional
    string
  • titlePflicht
    React.ReactNode
  • descriptionoptional
    string
  • chaptersPflicht
    RotateChapter[]

    One chapter per screen (phone, laptop, cube) or per side of the card

  • screensoptional
    React.ReactNode[]

    One screen per chapter (phone, laptop, cube)

  • viewportoptional
    number

    Render 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 }

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-current und aria-live, damit Screenreader Zweck und Zustand ansagen.
  • 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, 324 Zeilen
components/ui/rotate-showcase.tsx
'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>
  );
}

Häufige Fragen

Wie installiere ich Dreh-Showcase?

Mit aiw add @ai-website/rotate-showcase. Der Befehl legt die Datei components/ui/rotate-showcase.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind section, button, reveal, mockup, device-3d und scroll-scene. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Dreh-Showcase?

4 Varianten, Telefon, das sich dreht, Laptop von hinten nach vorn, Würfel aus Seiten und Drehende Karte. Du wählst sie mit der Prop variant, zum Beispiel variant="laptop".

Kann ein KI-Assistent Dreh-Showcase einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Dreh-Showcase mit aiw_components und installieren sie mit aiw_add @ai-website/rotate-showcase. Nenn im Prompt die Variante, etwa variant="phone", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert