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
Variants
Details
What Split reveal brings along, read from the registry and the source.
- Category
- Scroll reveals and transitions
- Area
- Marketing
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/split-reveal.tsx
- npm packages
- None
- Languages
- Texts come through props
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
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'>.
variantoptionalSplitRevealVariantdoorsshutterdiagonalticketslats
partsrequiredstring[]The message on the parts: two halves (doors, shutter, diagonal, ticket) or one word per slat
eyebrowoptionalstringtitleoptionalReact.ReactNodeWhat appears behind: heading and text
descriptionoptionalstringmediaoptionalReact.ReactNodePicture behind the parts (doors, diagonal, slats)
factsoptionalSplitFact[]Facts behind the parts: hours (doors), figures (shutter), event details (ticket)
actionoptional{ label: string; href: string }stuboptionalstringticket: 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
'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.