Zum Inhalt springen
UI Kit
Accent

Geteilte Enthüllung

Zwei oder mehr Teile, die sich beim Scrollen öffnen und freigeben, was dahinter liegt. Die Teile tragen die Botschaft, je eine halbe Überschrift oder ein Wort. Ohne JavaScript steht die Botschaft als Überschrift über dem, was sie zeigt.

Eine von 3 Components in Scroll-Reveals und Übergänge

5 Variants

Variants

Türen

Zwei Türen mit je einer halben Überschrift gleiten auseinander. Dahinter der Laden, sein Name und die Öffnungszeiten.

Im Studio öffnen

Rollo mit Zahlen

Eine obere und eine untere Hälfte teilen sich an der Mittellinie, die Zahlen dahinter kommen nacheinander.

Im Studio öffnen

Diagonaler Schnitt

Das Fenster ist entlang der Diagonale geschnitten, beide Hälften gleiten entlang des Schnitts hinaus und lassen das Bild zurück.

Im Studio öffnen

Abgerissenes Ticket

Ein Ticket reißt an der Perforation, die Hälften treiben drehend auseinander, und darunter liegen die Angaben zum Abend.

Im Studio öffnen

Fünf Lamellen

Fünf Lamellen mit je einem Wort gleiten abwechselnd nach oben und unten. Erst liest sich der Satz, dann das Bild dahinter.

Im Studio öffnen

Details

Was Geteilte Enthüllung mitbringt, aus Registry und Quellcode gelesen.

Bereich
Marketing
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/split-reveal.tsx
Bringt mit
utilskitbuttoniconrevealsectionscroll-scene
npm-Pakete
Keine
Sprachen
Texte kommen über Props

Props

Die Schnittstelle SplitRevealProps aus components/ui/split-reveal.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
    SplitRevealVariant

    doorsshutterdiagonalticketslats

  • partsPflicht
    string[]

    The message on the parts: two halves (doors, shutter, diagonal, ticket) or one word per slat

  • eyebrowoptional
    string
  • titleoptional
    React.ReactNode

    What appears behind: heading and text

  • descriptionoptional
    string
  • mediaoptional
    React.ReactNode

    Picture behind the parts (doors, diagonal, slats)

  • factsoptional
    SplitFact[]

    Facts behind the parts: hours (doors), figures (shutter), event details (ticket)

  • actionoptional
    { label: string; href: string }
  • stuboptional
    string

    ticket: small print on the stub, e.g. the seat or a number

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • 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, 363 Zeilen
components/ui/split-reveal.tsx
'use client';

import { useCallback, useEffect, useRef } from 'react';
import { cn, round } from '@/lib/kit';
import { Eyebrow } from '@/components/ui/section';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { lerp, scrollToProgress, smooth, span, useLive, useSceneProgress } from '@/components/ui/scroll-scene';

/*
 * Two or more parts that open while the page scrolls and give way to what lies behind them. The
 * parts carry the message (half a headline each, a word each, the halves of a ticket), so the
 * opening is the sentence, not a decoration. The section stands still for about two screens.
 *
 * Without JavaScript and with reduced motion nothing is closed: the message stands as a heading
 * above what it reveals, in a calm normal layout.
 *
 *   doors     two doors with half a headline each slide apart, a shop and its hours appear
 *   shutter   an upper and a lower half part, the figures behind them count up to view
 *   diagonal  a picture cut along its diagonal, both halves glide out along the cut
 *   ticket    a ticket tears at its perforation, the event details are underneath
 *   slats     five slats with a word each slide up and down in turn, a picture remains
 */

export type SplitRevealVariant = 'doors' | 'shutter' | 'diagonal' | 'ticket' | 'slats';

export interface SplitFact {
  label: string;
  value: string;
  icon?: IconName;
}

export interface SplitRevealProps extends Omit<React.HTMLAttributes<HTMLElement>, 'title'> {
  variant?: SplitRevealVariant;
  /** The message on the parts: two halves (doors, shutter, diagonal, ticket) or one word per slat */
  parts: string[];
  eyebrow?: string;
  /** What appears behind: heading and text */
  title?: React.ReactNode;
  description?: string;
  /** Picture behind the parts (doors, diagonal, slats) */
  media?: React.ReactNode;
  /** Facts behind the parts: hours (doors), figures (shutter), event details (ticket) */
  facts?: SplitFact[];
  action?: { label: string; href: string };
  /** ticket: small print on the stub, e.g. the seat or a number */
  stub?: string;
}

type Els = Record<string, HTMLElement | null>;
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));
};

/** The content behind settles from a little zoom and arrives in two steps */
function applyBehind(e: Els, p: number, from = 0.1, text: [number, number] = [0.55, 0.85]) {
  const z = smooth(span(p, from, 0.7));
  tf(e.media, `scale(${round(lerp(1.14, 1, z), 4)})`);
  const x = smooth(span(p, text[0], text[1]));
  op(e.content, x);
  tf(e.content, `translate3d(0, ${round(24 * (1 - x), 1)}px, 0)`);
}

const APPLY: Record<SplitRevealVariant, (p: number, e: Els) => void> = {
  doors(p, e) {
    const x = smooth(span(p, 0.12, 0.62));
    tf(e.a, `translate3d(${round(-101 * x, 2)}%, 0, 0)`);
    tf(e.b, `translate3d(${round(101 * x, 2)}%, 0, 0)`);
    applyBehind(e, p);
  },
  shutter(p, e) {
    const x = smooth(span(p, 0.12, 0.6));
    tf(e.a, `translate3d(0, ${round(-101 * x, 2)}%, 0)`);
    tf(e.b, `translate3d(0, ${round(101 * x, 2)}%, 0)`);
    // The heading arrives while the halves open, the figures follow one by one
    applyBehind(e, p, 0.05, [0.28, 0.55]);
    for (let i = 0; i < 4; i++) {
      const y = smooth(span(p, 0.38 + i * 0.07, 0.55 + i * 0.07));
      op(e[`f${i}`], y);
      tf(e[`f${i}`], `translate3d(0, ${round(20 * (1 - y), 1)}px, 0)`);
    }
  },
  diagonal(p, e) {
    const x = smooth(span(p, 0.12, 0.62));
    // Both halves leave along the cut, one up and left, one down and right
    tf(e.a, `translate3d(${round(-62 * x, 2)}%, ${round(-62 * x, 2)}%, 0)`);
    tf(e.b, `translate3d(${round(62 * x, 2)}%, ${round(62 * x, 2)}%, 0)`);
    applyBehind(e, p);
  },
  ticket(p, e) {
    const x = smooth(span(p, 0.15, 0.6));
    tf(e.a, `translate3d(${round(-46 * x, 2)}vw, ${round(10 * x, 2)}svh, 0) rotate(${round(-9 * x, 2)}deg)`);
    tf(e.b, `translate3d(${round(46 * x, 2)}vw, ${round(-8 * x, 2)}svh, 0) rotate(${round(7 * x, 2)}deg)`);
    op(e.a, 1 - span(p, 0.45, 0.65));
    op(e.b, 1 - span(p, 0.45, 0.65));
    const y = smooth(span(p, 0.35, 0.7));
    op(e.content, y);
    tf(e.content, `scale(${round(lerp(0.92, 1, y), 4)})`);
  },
  slats(p, e) {
    // Columns on wide screens move up and down, rows on phones move left and right
    const rows = window.innerWidth < 640;
    for (let i = 0; i < 5; i++) {
      const x = smooth(span(p, 0.1 + i * 0.06, 0.48 + i * 0.06));
      const d = round((i % 2 ? 101 : -101) * x, 2);
      tf(e[`s${i}`], rows ? `translate3d(${d}%, 0, 0)` : `translate3d(0, ${d}%, 0)`);
    }
    applyBehind(e, p);
  },
};

/* ------------------------------------------------------------------ */

function Head({ eyebrow, title, description, center }: { eyebrow?: string; title?: React.ReactNode; description?: string; center?: boolean }) {
  if (!title && !eyebrow) return null;
  return (
    <div className={cn('max-w-[40rem]', center && 'mx-auto text-center')}>
      {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>
  );
}

/** Opening hours or details as a quiet list */
function FactList({ facts, className }: { facts: SplitFact[]; className?: string }) {
  return (
    <dl className={cn('divide-y divide-border', className)}>
      {facts.map((f, i) => (
        <div key={i} className="flex items-center justify-between gap-6 py-3 text-[14.5px]">
          <dt className="flex items-center gap-2.5 text-muted-foreground">
            {f.icon && <Icon name={f.icon} size={16} className="text-primary" />}
            {f.label}
          </dt>
          <dd className="font-medium tabular-nums text-foreground">{f.value}</dd>
        </div>
      ))}
    </dl>
  );
}

function Figures({ facts, set }: { facts: SplitFact[]; set?: (k: string) => (n: HTMLElement | null) => void }) {
  return (
    <dl className="grid grid-cols-2 gap-px overflow-hidden rounded-2xl border border-border bg-border lg:grid-cols-4">
      {facts.slice(0, 4).map((f, i) => (
        <div key={i} className="bg-card p-5 sm:p-7">
          <div ref={set?.(`f${i}`)} className="flex flex-col-reverse">
            <dt className="mt-3 text-[13.5px] leading-snug text-muted-foreground">{f.label}</dt>
            <dd className="text-[clamp(2rem,1.4rem+2.2vw,3.5rem)] font-semibold leading-none tracking-[-0.03em] tabular-nums text-foreground">{f.value}</dd>
          </div>
        </div>
      ))}
    </dl>
  );
}

/** The message in very large type, sized to sit in half the window */
const BIG = 'text-[clamp(3rem,1.6rem+6.4vw,8.5rem)] font-semibold leading-[0.95] tracking-[-0.045em]';
/** diagonal: each half is a triangle, so the words get a little less size on phones to stay inside it */
const DIAG = 'max-sm:text-[clamp(2.25rem,1rem+6vw,3rem)]';

function TicketHalf({ side, parts, stub, facts }: { side: 'a' | 'b'; parts: string[]; stub?: string; facts?: SplitFact[] }) {
  const a = side === 'a';
  return (
    <div className={cn('relative flex h-56 flex-col justify-between overflow-hidden bg-primary p-6 shadow-[var(--shadow-lg)] text-primary-foreground sm:h-64 sm:p-8', a ? 'rounded-l-2xl' : 'rounded-r-2xl')}>
      {/* The perforation: half circles cut out at the seam, dotted line between them */}
      <span aria-hidden data-clip-ok className={cn('absolute top-0 size-6 -translate-y-1/2 rounded-full bg-background', a ? '-right-3' : '-left-3')} />
      <span aria-hidden data-clip-ok className={cn('absolute bottom-0 size-6 translate-y-1/2 rounded-full bg-background', a ? '-right-3' : '-left-3')} />
      {a && <span aria-hidden className="absolute inset-y-5 right-0 border-r-2 border-dashed border-[color-mix(in_oklab,var(--primary-foreground)_45%,transparent)]" />}
      <p className="text-[12.5px] font-medium uppercase tracking-[0.14em] opacity-80">{a ? facts?.[0]?.value : stub}</p>
      <p className={cn('text-[clamp(2rem,1.4rem+2.6vw,3.5rem)] font-semibold leading-[0.95] tracking-[-0.035em]', !a && 'text-right')}>{a ? parts[0] : parts[1]}</p>
    </div>
  );
}

/* ------------------------------------------------------------------ */

/** Parts that open while the page scrolls and reveal what lies behind them. */
export function SplitReveal({ variant = 'doors', parts, eyebrow, title, description, media, facts = [], action, stub, className, onFocus, ...rest }: SplitRevealProps) {
  const live = useLive();
  const els = useRef<Els>({});
  const last = useRef(0);
  const set = (k: string) => (n: HTMLElement | null) => {
    els.current[k] = n;
  };
  const apply = useCallback(
    (p: number) => {
      last.current = p;
      APPLY[variant](p, els.current);
    },
    [variant],
  );
  const track = useSceneProgress<HTMLDivElement>(live, { self: false, onProgress: apply });
  useEffect(() => {
    if (live) apply(last.current);
  }, [live, apply]);

  const message = parts.join(' ');
  const actionEl = action && (
    <div className="max-sm:w-full max-sm:[&>span]:w-full md:shrink-0">
      <Button href={action.href} icon="arrow-right" magnetic className="max-sm:w-full">
        {action.label}
      </Button>
    </div>
  );

  /* ---------- Finished layout: no JavaScript, reduced motion, server ---------- */
  if (!live) {
    return (
      <section {...rest} onFocus={onFocus} className={cn('relative overflow-x-clip py-20 sm:py-24 lg:py-32', className)}>
        <div className="kit-container">
          <Reveal>
            <p className={cn(BIG, 'text-balance text-foreground')}>{message}</p>
          </Reveal>
          <div className="mt-10 sm:mt-14">
            {variant === 'shutter' && (
              <div className="grid gap-10">
                <Head eyebrow={eyebrow} title={title} description={description} />
                {facts.length > 0 && <Figures facts={facts} />}
              </div>
            )}
            {variant === 'ticket' && (
              <div className="mx-auto max-w-[34rem] rounded-2xl border border-border bg-card p-6 shadow-[var(--highlight),var(--shadow-md)] sm:p-8">
                <Head eyebrow={eyebrow} title={title} description={description} />
                <FactList facts={facts} className="mt-6" />
                {actionEl && <div className="mt-6">{actionEl}</div>}
              </div>
            )}
            {(variant === 'doors' || variant === 'diagonal' || variant === 'slats') && (
              // Phones: the picture on top and the text below it on a dark ground; from 768 px the text lies on the picture
              <div className="dark relative isolate overflow-hidden rounded-2xl bg-bezel text-foreground">
                <div className="relative aspect-[4/3] md:absolute md:inset-0 md:-z-10 md:aspect-auto [&>*]:size-full">{media}</div>
                <span className="absolute inset-0 -z-10 hidden bg-[linear-gradient(to_top,color-mix(in_oklab,var(--background)_92%,transparent),color-mix(in_oklab,var(--background)_74%,transparent)_48%,transparent_85%)] md:block" />
                <div className="flex flex-col gap-6 p-6 sm:p-10 md:min-h-[34rem] md:flex-row md:items-end md:justify-between">
                  <Head eyebrow={eyebrow} title={title} description={description} />
                  {variant === 'doors' && facts.length > 0 ? (
                    <div className="w-full max-w-[20rem] rounded-xl border border-border bg-[color-mix(in_oklab,var(--card)_86%,transparent)] px-5 py-2 backdrop-blur-md">
                      <FactList facts={facts} />
                    </div>
                  ) : (
                    actionEl
                  )}
                </div>
              </div>
            )}
          </div>
        </div>
      </section>
    );
  }

  /* ---------- Live scene ---------- */
  // What lies behind arrives at the end; tabbing into it (the action) brings the scene there
  const onSceneFocus = (e: React.FocusEvent<HTMLElement>) => {
    onFocus?.(e);
    if (els.current.content?.contains(e.target as Node)) scrollToProgress(track.current, 0.97);
  };
  return (
    <section {...rest} onFocus={onSceneFocus} className={cn('relative overflow-x-clip', className)}>
      <div ref={track} className="relative" style={{ height: '260svh' }}>
        <div className="sticky top-0 h-svh overflow-clip">
          {/* What lies behind */}
          {(variant === 'doors' || variant === 'diagonal' || variant === 'slats') && (
            <>
              <div ref={set('media')} data-clip-ok className="absolute inset-0 will-change-transform [&>*]:size-full">
                {media}
              </div>
              {/* On phones the text block is taller, so the veil reaches higher */}
              <span className="dark absolute inset-0 bg-[linear-gradient(to_top,color-mix(in_oklab,var(--background)_90%,transparent),color-mix(in_oklab,var(--background)_30%,transparent)_45%,transparent_75%)] max-md:bg-[linear-gradient(to_top,color-mix(in_oklab,var(--background)_92%,transparent),color-mix(in_oklab,var(--background)_82%,transparent)_50%,color-mix(in_oklab,var(--background)_40%,transparent)_72%,transparent_92%)]" />
              <div ref={set('content')} className="dark absolute inset-x-0 bottom-0" style={{ opacity: 0 }}>
                <div className="kit-container flex flex-col gap-6 pb-10 sm:pb-14 md:flex-row md:items-end md:justify-between">
                  <Head eyebrow={eyebrow} title={title} description={description} />
                  {variant === 'doors' && facts.length > 0 ? (
                    <div className="w-full max-w-[20rem] rounded-xl border border-border bg-[color-mix(in_oklab,var(--card)_86%,transparent)] px-5 py-2 backdrop-blur-md">
                      <FactList facts={facts} />
                    </div>
                  ) : (
                    actionEl
                  )}
                </div>
              </div>
            </>
          )}
          {variant === 'shutter' && (
            <div className="kit-dots absolute inset-0 flex items-center">
              <div ref={set('content')} className="kit-container grid w-full gap-10" style={{ opacity: 0 }}>
                <Head eyebrow={eyebrow} title={title} description={description} />
                {facts.length > 0 && <Figures facts={facts} set={set} />}
              </div>
            </div>
          )}
          {variant === 'ticket' && (
            <div className="absolute inset-0 flex items-center justify-center px-6">
              <div ref={set('content')} className="w-full max-w-[34rem] rounded-2xl border border-border bg-card p-6 shadow-[var(--highlight),var(--shadow-lg)] sm:p-8" style={{ opacity: 0 }}>
                <Head eyebrow={eyebrow} title={title} description={description} />
                <FactList facts={facts} className="mt-6" />
                {actionEl && <div className="mt-6">{actionEl}</div>}
              </div>
            </div>
          )}

          {/* The parts */}
          <p className="sr-only">{message}</p>
          {variant === 'doors' && (
            <div aria-hidden className="absolute inset-0 grid grid-cols-2">
              <div ref={set('a')} className="flex items-center justify-end overflow-hidden border-r border-[color-mix(in_oklab,var(--background)_30%,transparent)] bg-foreground pr-[clamp(0.75rem,2vw,2rem)] text-background will-change-transform">
                <span className={cn(BIG, 'text-right')}>{parts[0]}</span>
              </div>
              <div ref={set('b')} className="flex items-center justify-start overflow-hidden bg-foreground pl-[clamp(0.75rem,2vw,2rem)] text-background will-change-transform">
                <span className={BIG}>{parts[1]}</span>
              </div>
            </div>
          )}
          {variant === 'shutter' && (
            <div aria-hidden className="absolute inset-0 grid grid-rows-2">
              <div ref={set('a')} className="flex items-end justify-center border-b border-border bg-surface pb-[clamp(0.5rem,1.5svh,1.25rem)] text-foreground will-change-transform">
                <span className={BIG}>{parts[0]}</span>
              </div>
              <div ref={set('b')} className="flex items-start justify-center bg-surface pt-[clamp(0.5rem,1.5svh,1.25rem)] will-change-transform">
                <span className={cn(BIG, 'kit-accent-text')}>{parts[1]}</span>
              </div>
            </div>
          )}
          {variant === 'diagonal' && (
            <div aria-hidden className="absolute inset-0">
              <div ref={set('a')} className="absolute inset-0 bg-surface will-change-transform [clip-path:polygon(0_0,100%_0,0_100%)]">
                <span className={cn(BIG, DIAG, 'absolute left-[6%] top-[14%] max-w-[80%] text-foreground sm:max-w-[60%]')}>{parts[0]}</span>
              </div>
              <div ref={set('b')} className="absolute inset-0 bg-surface-2 will-change-transform [clip-path:polygon(100%_0,100%_100%,0_100%)]">
                <span className={cn(BIG, DIAG, 'kit-accent-text absolute bottom-[14%] right-[5%] max-w-[80%] text-right sm:right-[6%] sm:max-w-[60%]')}>{parts[1]}</span>
              </div>
            </div>
          )}
          {variant === 'ticket' && (
            <div aria-hidden className="absolute inset-0 flex items-center justify-center px-4">
              <div className="grid w-full max-w-[44rem] grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)]">
                <div ref={set('a')} className="will-change-transform">
                  <TicketHalf side="a" parts={parts} stub={stub} facts={facts} />
                </div>
                <div ref={set('b')} className="will-change-transform">
                  <TicketHalf side="b" parts={parts} stub={stub} facts={facts} />
                </div>
              </div>
            </div>
          )}
          {variant === 'slats' && (
            <div aria-hidden className="absolute inset-0 grid grid-rows-5 sm:grid-cols-5 sm:grid-rows-1">
              {Array.from({ length: 5 }, (_, i) => (
                <div key={i} ref={set(`s${i}`)} className={cn('flex items-center justify-center overflow-hidden border-b border-[color-mix(in_oklab,var(--foreground)_10%,transparent)] will-change-transform last:border-0 sm:border-b-0 sm:border-r', i % 2 ? 'bg-surface-2' : 'bg-surface')}>
                  <span className="text-[clamp(2.5rem,1rem+3.4vw,4.5rem)] font-semibold tracking-[-0.04em] text-foreground">{parts[i] ?? ''}</span>
                </div>
              ))}
            </div>
          )}
        </div>
      </div>
    </section>
  );
}

Häufige Fragen

Wie installiere ich Geteilte Enthüllung?

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

Welche Varianten hat Geteilte Enthüllung?

5 Varianten, Türen, Rollo mit Zahlen, Diagonaler Schnitt, Abgerissenes Ticket und Fünf Lamellen. Du wählst sie mit der Prop variant, zum Beispiel variant="shutter".

Kann ein KI-Assistent Geteilte Enthüllung einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Geteilte Enthüllung mit aiw_components und installieren sie mit aiw_add @ai-website/split-reveal. Nenn im Prompt die Variante, etwa variant="doors", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Geteilte Enthüllung ohne Code anpassen?

Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden SplitReveal-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert