Skip to content
UI Kit
Accent color

Split reveal

Two or more parts that open while the page scrolls and give way to what lies behind. The parts carry the message, half a headline or a word each. Without JavaScript the message stands as a heading above what it reveals.

One of 3 components in Scroll reveals and transitions

5 variants

Variants

Doors

Two doors with half a headline each slide apart. Behind them the shop, its name and its opening hours.

Open in Studio

Shutter with figures

An upper and a lower half part along the middle line, the figures behind them arrive one after another.

Open in Studio

Diagonal cut

The window is cut along its diagonal, both halves glide out along the cut and leave the picture behind.

Open in Studio

Torn ticket

A ticket tears at its perforation, the halves drift apart and turn, and the event details lie underneath.

Open in Studio

Five slats

Five slats with one word each slide up and down in turn. The sentence reads first, then the picture behind it.

Open in Studio

Details

What Split reveal brings along, read from the registry and the source.

Variants
5
Version
1.0.0 · since 1.1.0
File
components/ui/split-reveal.tsx
Brings along
utilskitbuttoniconrevealsectionscroll-scene
npm packages
None
Languages
Texts come through props

Props

The SplitRevealProps interface from components/ui/split-reveal.tsx, with the comments from the code. Also takes every prop of Omit<React.HTMLAttributes<HTMLElement>, 'title'>.

  • variantoptional
    SplitRevealVariant

    doorsshutterdiagonalticketslats

  • partsrequired
    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

Accessibility

Read from the source of the component.

  • Adds text for screen readers where icons and graphics stand alone.
  • 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, 363 lines
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>
  );
}

Frequent questions

How do I install Split reveal?

With aiw add @ai-website/split-reveal. The command puts components/ui/split-reveal.tsx into your project, the code is yours from then on. It brings along button, icon, reveal, section and scroll-scene. It needs no further npm packages.

Which variants does Split reveal have?

5 variants, Doors, Shutter with figures, Diagonal cut, Torn ticket and Five slats. You pick one with the variant prop, for example variant="shutter".

Can an AI assistant build with Split reveal?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Split reveal with aiw_components and install it with aiw_add @ai-website/split-reveal. Name the variant in your prompt, for example variant="doors", and the assistant writes your content straight into the props.

Can I adjust Split reveal without code?

Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching SplitReveal code to copy.

Often searched

Jump to

Copied to the clipboard