Section-Übergänge
Wie eine Section an die nächste übergibt. Die Seite blendet in ein dunkles Band oder einen Akzentschleier, eine Section gleitet wie ein Vorhang über die letzte, öffnet sich durch einen Kreis oder schiebt die vorige als Karte zurück. Ohne JavaScript steht jede Section auf ihrem eigenen Hintergrund.
Eine von 3 Components in Scroll-Reveals und Übergänge
Variants
Details
Was Section-Übergänge mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Scroll-Reveals und Übergänge
- Bereich
- Marketing
- Variants
- 5
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/section-transitions.tsx
- npm-Pakete
- Keine
- Sprachen
- Texte kommen über Props
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle SectionTransitionsProps aus components/ui/section-transitions.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von React.HTMLAttributes<HTMLElement> an.
variantoptionalSectionTransitionsVariantdark-bandaccent-washcurtaincirclecard-shrink
itemsPflichtTransitionSection[]
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bewegt sich mit dem Scrollen, der Fortschritt folgt der Position auf der Seite.
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 230 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich Section-Übergänge?
Mit aiw add @ai-website/section-transitions. Der Befehl legt die Datei components/ui/section-transitions.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, section und scroll-scene. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Section-Übergänge?
5 Varianten, In ein dunkles Band, Akzentschleier, Vorhang, Durch einen Kreis und Zurück in eine Karte. Du wählst sie mit der Prop variant, zum Beispiel variant="accent-wash".
Kann ein KI-Assistent Section-Übergänge einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Section-Übergänge mit aiw_components und installieren sie mit aiw_add @ai-website/section-transitions. Nenn im Prompt die Variante, etwa variant="dark-band", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Section-Übergänge ohne Code anpassen?
Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden SectionTransitions-Code zum Kopieren.