Section transitions
How one section hands over to the next. The page fades into a dark band or an accent wash, a section slides over the last like a curtain, opens through a circle or pushes the one before back into a card. Without JavaScript every section stands on its own background.
One of 3 components in Scroll reveals and transitions
Variants
Details
What Section transitions 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/section-transitions.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 SectionTransitionsProps interface from components/ui/section-transitions.tsx, with the comments from the code. Also takes every prop of React.HTMLAttributes<HTMLElement>.
variantoptionalSectionTransitionsVariantdark-bandaccent-washcurtaincirclecard-shrink
itemsrequiredTransitionSection[]
Accessibility
Read from the source of the component.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Moves with the scroll, its progress follows the position on the page.
- Brings animations of its own, calmly timed and without wobble.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 230 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 } from '@/components/ui/icon';
import { lerp, scrollToProgress, smooth, span, useLive, useSceneProgress } from '@/components/ui/scroll-scene';
/*
* Transitions between sections: how one section hands over to the next. A run of sections is
* passed as items; each item is a calm section with text, points and a picture.
*
* dark-band the page fades into a dark band and back out, text fades with its background
* accent-wash the page takes on a wash of the accent for one section
* curtain each section slides up over the one before, which steps back under a veil
* circle each section opens through a growing circle from the middle
* card-shrink the section before shrinks into a rounded card while the next one rises
*
* The first two keep the page in its normal flow and fade one background layer. The other three
* pin the run of sections for its length. Only opacity, transform and clip-path move.
*
* Without JavaScript and with reduced motion every section stands after the other on its own
* background (band, accent wash), nothing overlaps.
*/
export type SectionTransitionsVariant = 'dark-band' | 'accent-wash' | 'curtain' | 'circle' | 'card-shrink';
export type SectionTone = 'base' | 'surface' | 'dark' | 'accent';
export interface TransitionSection {
eyebrow?: string;
title: string;
text?: string;
points?: string[];
/** Background of the section; in dark-band and accent-wash the marked one becomes the band */
tone?: SectionTone;
media?: React.ReactNode;
action?: { label: string; href: string };
}
export interface SectionTransitionsProps extends React.HTMLAttributes<HTMLElement> {
variant?: SectionTransitionsVariant;
items: TransitionSection[];
}
const TONE: Record<SectionTone, string> = {
base: 'bg-background',
surface: 'bg-surface',
dark: 'dark bg-bezel',
accent: 'bg-[color-mix(in_oklab,var(--primary)_14%,var(--background))]',
};
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));
};
/** One calm section: text with points beside a picture, the picture switches sides */
function Body({ it, i, compact }: { it: TransitionSection; i: number; compact?: boolean }) {
return (
<div className={cn('kit-container grid w-full grid-cols-1 items-center gap-8 md:grid-cols-2 md:gap-14', compact && 'gap-5')}>
<div className={cn(i % 2 && 'md:order-2')}>
{it.eyebrow && <Eyebrow>{it.eyebrow}</Eyebrow>}
<h2 className={cn('text-balance font-semibold text-foreground', compact ? 'text-[clamp(1.5rem,1.1rem+1.6vw,2.5rem)] leading-tight tracking-[-0.02em]' : 'text-h2')}>{it.title}</h2>
{it.text && <p className={cn('mt-4 max-w-[32rem] text-pretty text-muted-foreground', compact ? 'text-[15px] leading-relaxed' : 'text-lead')}>{it.text}</p>}
{it.points && it.points.length > 0 && (
<ul className={cn('mt-6 grid gap-2.5', compact && 'mt-4 max-md:hidden')}>
{it.points.map((pt) => (
<li key={pt} className="flex items-start gap-2.5 text-[14.5px] text-foreground">
<Icon name="check-circle" size={17} className="mt-0.5 shrink-0 text-primary" />
{pt}
</li>
))}
</ul>
)}
{it.action && (
<div className={cn('mt-7', compact && 'mt-5')}>
<Button href={it.action.href} variant={it.tone === 'dark' ? 'primary' : 'secondary'} icon="arrow-right" className="max-sm:w-full">
{it.action.label}
</Button>
</div>
)}
</div>
{it.media && <div className={cn('relative overflow-hidden rounded-2xl border border-border bg-surface [&>*]:size-full', compact ? 'aspect-[16/10] max-md:max-h-[30svh] max-md:w-full' : 'aspect-[4/3]')}>{it.media}</div>}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Bands: dark-band, accent-wash */
/* ------------------------------------------------------------------ */
function Bands({ variant, items, live }: { variant: 'dark-band' | 'accent-wash'; items: TransitionSection[]; live: boolean }) {
const root = useRef<HTMLDivElement>(null);
const layer = useRef<HTMLDivElement>(null);
const band = (it: TransitionSection) => (variant === 'dark-band' ? it.tone === 'dark' : it.tone === 'accent');
useEffect(() => {
const el = root.current;
if (!el || !live) return;
const secs = Array.from(el.querySelectorAll<HTMLElement>(':scope > [data-sec]'));
const bodies = secs.map((s) => s.querySelector<HTMLElement>('[data-body]'));
let frame = 0;
const update = () => {
frame = 0;
const vh = window.innerHeight;
// How much each section owns the window: full while it spans the middle, a soft hand over at its edges
const w = secs.map((s) => {
const r = s.getBoundingClientRect();
return smooth(span((vh * 0.78 - r.top) / (vh * 0.4), 0, 1)) * smooth(span((r.bottom - vh * 0.22) / (vh * 0.4), 0, 1));
});
let on = 0;
secs.forEach((s, i) => {
if (s.hasAttribute('data-band')) on = Math.max(on, w[i]);
});
op(layer.current, on);
// Text fades with its background: band text needs the band, the rest needs the page
secs.forEach((s, i) => op(bodies[i], s.hasAttribute('data-band') ? lerp(0.08, 1, w[i]) : lerp(1, 0.08, on)));
};
const onScroll = () => {
if (!frame) frame = requestAnimationFrame(update);
};
update();
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onScroll);
bodies.forEach((b) => b?.style.removeProperty('opacity'));
};
}, [live]);
return (
<div ref={root} className="relative">
{live && (
<div aria-hidden className="pointer-events-none sticky top-0 -mb-[100svh] h-svh">
<div ref={layer} className={cn('absolute inset-0', variant === 'dark-band' ? 'dark bg-bezel' : TONE.accent)} style={{ opacity: 0 }}>
{variant === 'dark-band' && <div className="absolute inset-0 bg-[radial-gradient(60%_50%_at_50%_0%,color-mix(in_oklab,var(--primary)_22%,transparent),transparent)]" />}
</div>
</div>
)}
{items.map((it, i) => (
<div key={i} data-sec data-band={band(it) ? '' : undefined} className={cn('relative flex min-h-[86svh] items-center py-20 sm:py-24', !live && band(it) && (variant === 'dark-band' ? TONE.dark : TONE.accent), band(it) && variant === 'dark-band' && 'dark')}>
<div data-body className="w-full">
<Body it={it} i={i} />
</div>
</div>
))}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Pinned stack: curtain, circle, card-shrink */
/* ------------------------------------------------------------------ */
function applyStack(variant: 'curtain' | 'circle' | 'card-shrink', p: number, n: number, e: Els) {
// k[i]: how far section i has arrived (0 below or closed, 1 in place); section 0 is always there
const k = Array.from({ length: n }, (_, i) => (i === 0 ? 1 : smooth(span(p * (n - 1) - (i - 1), 0.08, 0.92))));
for (let i = 0; i < n; i++) {
const sec = e[`s${i}`];
// covered: how far the next section has come over this one
const covered = i < n - 1 ? k[i + 1] : 0;
const back = variant === 'card-shrink' ? 0.14 : variant === 'circle' ? 0.06 : 0.05;
const scale = round(1 - covered * back, 4);
if (variant === 'circle') {
if (sec && i > 0) sec.style.clipPath = `circle(${round(k[i] * 75, 3)}% at 50% 55%)`;
tf(sec, `scale(${scale})`);
} else {
tf(sec, `translate3d(0, ${round((1 - k[i]) * 100, 3)}%, 0) scale(${scale})`);
// card-shrink: the section before becomes a rounded card while it moves back
if (variant === 'card-shrink' && sec) sec.style.clipPath = `inset(0 round ${round(covered * 28, 1)}px)`;
}
op(e[`v${i}`], covered * (variant === 'circle' ? 0.35 : 0.6));
}
}
function Stack({ variant, items, live }: { variant: 'curtain' | 'circle' | 'card-shrink'; items: TransitionSection[]; live: boolean }) {
const n = items.length;
const els = useRef<Els>({});
const set = (k: string) => (node: HTMLElement | null) => {
els.current[k] = node;
};
const apply = useCallback((p: number) => applyStack(variant, p, n, els.current), [variant, n]);
const track = useSceneProgress<HTMLDivElement>(live, { self: false, onProgress: apply });
if (!live) {
return (
<div>
{items.map((it, i) => (
<div key={i} className={cn('relative flex items-center py-20 sm:py-24', TONE[it.tone ?? 'base'])}>
<Body it={it} i={i} />
</div>
))}
</div>
);
}
return (
<div ref={track} className="relative" style={{ height: `${round((n - 1) * 110 + 100, 2)}svh` }}>
<div className="sticky top-0 h-svh overflow-clip bg-bezel">
{items.map((it, i) => (
<div
key={i}
ref={set(`s${i}`)}
className={cn('absolute inset-0 flex items-center overflow-hidden py-10 will-change-transform', TONE[it.tone ?? 'base'], variant !== 'circle' && i > 0 && 'rounded-t-[28px] shadow-[var(--shadow-lg)]')}
style={i > 0 ? (variant === 'circle' ? { clipPath: 'circle(0% at 50% 55%)' } : { transform: 'translate3d(0, 100%, 0)' }) : undefined}
onFocus={() => scrollToProgress(track.current, n > 1 ? i / (n - 1) : 0)}
>
<Body it={it} i={i} compact />
<span ref={set(`v${i}`)} aria-hidden className="pointer-events-none absolute inset-0 bg-bezel" style={{ opacity: 0 }} />
</div>
))}
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/** Transitions between sections: a band, a wash, a curtain, a circle or a card. */
export function SectionTransitions({ variant = 'dark-band', items, className, ...rest }: SectionTransitionsProps) {
const live = useLive();
return (
<section {...rest} className={cn('relative overflow-x-clip', className)}>
{variant === 'dark-band' || variant === 'accent-wash' ? <Bands variant={variant} items={items} live={live} /> : <Stack variant={variant} items={items} live={live} />}
</section>
);
}Frequent questions
How do I install Section transitions?
With aiw add @ai-website/section-transitions. The command puts components/ui/section-transitions.tsx into your project, the code is yours from then on. It brings along button, icon, section and scroll-scene. It needs no further npm packages.
Which variants does Section transitions have?
5 variants, Into a dark band, Accent wash, Curtain, Through a circle and Back into a card. You pick one with the variant prop, for example variant="accent-wash".
Can an AI assistant build with Section transitions?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Section transitions with aiw_components and install it with aiw_add @ai-website/section-transitions. Name the variant in your prompt, for example variant="dark-band", and the assistant writes your content straight into the props.
Can I adjust Section transitions without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching SectionTransitions code to copy.