Split Screen Layout
Eine Fläche in zwei Hälften. Klebendes Bild neben scrollenden Panels, Umschalter zwischen zwei Angeboten, hell und dunkel, entweder oder.
Zwei Hälften, eine Entscheidung
Eine geteilte Fläche teilt die Ansicht in zwei Hälften, die zusammenarbeiten, ein Bild und seine Geschichte oder zwei Angebote nebeneinander. Am Handy bekommt jede Fassung ein eigenes Layout, beide Hälften bleiben also klar.
Die „Klebende Hälfte“ hält das Bild fest, während die Texthälfte scrollt, und das Bild wechselt mit jedem Panel. Der „Umschalter“ stellt zwei Angebote nebeneinander, das gewählte öffnet sich bei Hover oder Fokus breit, am Handy wird er zum Schalter. „Hell und dunkel“ setzt eine helle und eine dunkle Hälfte Kante an Kante, jede mit eigenem Text, Bild und Aktion. „Entweder oder“ zeigt zwei Wahlmöglichkeiten als Karten mit rundem Oder dazwischen, die empfohlene im Akzent.
Nimm die klebende Hälfte für eine Geschichte mit drei bis fünf Schritten. Umschalter und Entweder oder passen zu zwei Zielgruppen, etwa Privat- und Geschäftskunden. Hell und dunkel trägt Gegensätze, vorher und nachher oder Tag und Nacht.
Variants
Details
Was Geteilte Fläche mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Layouts
- Bereich
- Layout
- Variants
- 4
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/split-screen.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 3 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle SplitScreenProps aus components/ui/split-screen.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalSplitScreenVarianthalvesswitchcontrasteither-or
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodepanelsPflichtSplitPanel[]reverseoptionalbooleanhalves: the picture half on the right
Eingebaute Texte
Diese Texte schreibt Geteilte Fläche selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 3 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| or | Englisch or | Deutsch oder |
| choose | Englisch Choose a side | Deutsch Seite wählen |
| more | Englisch Show details for {name} | Deutsch Details zu {name} zeigen |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Geteilte Fläche reagiert selbst auf die Tasten Pfeil links, Pfeil rechts, Pos1, Ende und Leertaste.
- Setzt aria-controls, aria-label, aria-labelledby und aria-selected, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen tab, tablist und tabpanel.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- 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).
- Bewegt sich mit dem Scrollen, der Fortschritt folgt der Position auf der Seite.
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 5 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 343 Zeilen
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { Eyebrow, Section } from '@/components/ui/section';
import { Reveal } from '@/components/ui/reveal';
import { Stagger } from '@/components/ui/motion';
import { Button } from '@/components/ui/button';
/*
* A screen in two halves.
*
* halves one half holds a picture that stays while the other half
* scrolls through its panels, the picture changes with them
* switch two offers side by side, the chosen one opens wide; on
* phones a switch on top shows one at a time
* contrast a light and a dark half edge to edge, each with its action
* either-or two choices as cards with a round "or" between them
*
* Every half is a `panel`: eyebrow, title, text, points, action and media.
*/
export type SplitScreenVariant = 'halves' | 'switch' | 'contrast' | 'either-or';
export interface SplitPanel {
/** Short name, used on the switch */
label?: string;
eyebrow?: string;
title: string;
text?: string;
points?: string[];
action?: { label: string; href: string };
media?: React.ReactNode;
}
const TEXTS = {
en: { or: 'or', choose: 'Choose a side', more: 'Show details for {name}' },
de: { or: 'oder', choose: 'Seite wählen', more: 'Details zu {name} zeigen' },
};
type Texts = (typeof TEXTS)['en'];
export interface SplitScreenProps {
variant?: SplitScreenVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
panels: SplitPanel[];
/** halves: the picture half on the right */
reverse?: boolean;
labels?: Partial<Texts>;
className?: string;
}
const focus = 'outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring';
function Points({ points, className }: { points?: string[]; className?: string }) {
if (!points?.length) return null;
return (
<Stagger as="ul" step={70} className={cn('space-y-2.5', className)}>
{points.map((pt) => (
<li key={pt} className="flex gap-3 text-[15px] leading-relaxed text-foreground">
<span className="mt-1 flex size-5 shrink-0 items-center justify-center rounded-full bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]">
<Icon name="check" size={12} strokeWidth={2.4} />
</span>
{pt}
</li>
))}
</Stagger>
);
}
function Head({ p, center = false, className }: { p: SplitScreenProps; center?: boolean; className?: string }) {
if (!p.title && !p.eyebrow) return null;
return (
<div className={cn('mb-12 max-w-[44rem] sm:mb-14', center && 'mx-auto text-center', className)}>
{p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
{p.title && <h2 className="text-balance text-h2 font-semibold text-foreground">{p.title}</h2>}
{p.description && <p className={cn('mt-5 text-pretty text-lead text-muted-foreground', center && 'mx-auto max-w-[36rem]')}>{p.description}</p>}
</div>
);
}
const fillImg = '[&>*]:size-full';
/* ------------------------------------------------------------------ */
function Halves({ p }: { p: SplitScreenProps }) {
const [active, setActive] = useState(0);
const uid = useId();
const refs = useRef<(HTMLElement | null)[]>([]);
useEffect(() => {
let frame = 0;
const update = () => {
frame = 0;
const line = window.innerHeight * 0.5;
let i = 0;
refs.current.forEach((el, j) => {
if (el && el.getBoundingClientRect().top <= line) i = j;
});
setActive(i);
};
const onScroll = () => {
if (!frame) frame = requestAnimationFrame(update);
};
update();
window.addEventListener('scroll', onScroll, { passive: true });
return () => {
cancelAnimationFrame(frame);
window.removeEventListener('scroll', onScroll);
};
}, []);
return (
<div className={cn('grid grid-cols-[minmax(0,1fr)] lg:grid-cols-2', p.reverse && 'lg:[&>*:first-child]:order-2')}>
{/* the picture half: sticky on desktop, the pictures cross over */}
<div className="relative hidden lg:block">
<div className="sticky top-0 h-dvh max-h-[56rem] overflow-hidden bg-muted">
{p.panels.map((pn, i) => (
<div key={i} aria-hidden={i !== active} data-clip-ok className={cn('absolute inset-0 transition-[opacity,transform] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none', fillImg, i === active ? 'scale-100 opacity-100' : 'invisible scale-[1.04] opacity-0')}>
{pn.media}
</div>
))}
<div aria-hidden className="absolute bottom-6 left-6 flex gap-1.5">
{p.panels.map((_, i) => (
<span key={i} className={cn('h-1 rounded-full bg-white/90 shadow-[0_1px_4px_rgb(0_0_0/0.3)] transition-[width,opacity] duration-500', i === active ? 'w-8' : 'w-3 opacity-50')} />
))}
</div>
</div>
</div>
<div className="kit-container lg:max-w-none lg:px-16 xl:px-24">
{(p.title || p.eyebrow) && (
<div className="pt-20 sm:pt-24 lg:pt-32">
<Head p={p} className="mb-0 sm:mb-0" />
</div>
)}
{p.panels.map((pn, i) => (
<section key={i} ref={(el) => void (refs.current[i] = el)} aria-labelledby={`${uid}-h${i}`} className="flex flex-col justify-center py-14 lg:min-h-[80dvh] lg:py-24">
<div data-clip-ok className={cn('relative mb-8 aspect-[4/3] overflow-hidden rounded-2xl border border-border bg-muted lg:hidden', fillImg)}>{pn.media}</div>
<Reveal className="max-w-[32rem]">
<p className="mb-4 text-[13px] font-medium tabular-nums text-subtle-foreground">
{String(i + 1).padStart(2, '0')} / {String(p.panels.length).padStart(2, '0')}
{pn.eyebrow && <span className="ml-3 text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">{pn.eyebrow}</span>}
</p>
<h3 id={`${uid}-h${i}`} className="text-balance text-[clamp(1.625rem,1.3rem+1.2vw,2.375rem)] font-semibold leading-[1.1] tracking-[-0.03em] text-foreground">
{pn.title}
</h3>
{pn.text && <p className="mt-5 text-pretty text-[17px] leading-relaxed text-muted-foreground">{pn.text}</p>}
<Points points={pn.points} className="mt-7" />
{pn.action && (
<div className="mt-8">
<Button href={pn.action.href} variant={i === p.panels.length - 1 ? 'primary' : 'outline'} icon="arrow-right" className="max-sm:w-full">
{pn.action.label}
</Button>
</div>
)}
</Reveal>
</section>
))}
</div>
</div>
);
}
function Switch({ p, t }: { p: SplitScreenProps; t: Texts }) {
const [on, setOn] = useState(0);
const id = useId();
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const key = (e: React.KeyboardEvent, i: number) => {
const n = p.panels.length;
const next = e.key === 'ArrowRight' ? (i + 1) % n : e.key === 'ArrowLeft' ? (i - 1 + n) % n : e.key === 'Home' ? 0 : e.key === 'End' ? n - 1 : -1;
if (next < 0) return;
e.preventDefault();
setOn(next);
tabs.current[next]?.focus();
};
return (
<>
<Head p={p} center />
{/* phones and tablets: a switch, desktop: the halves are the switch */}
<div role="tablist" aria-label={t.choose} className="mx-auto mb-6 grid max-w-[24rem] grid-cols-2 gap-1 rounded-xl border border-border bg-surface p-1 lg:hidden">
{p.panels.map((pn, i) => (
<button
key={i}
ref={(el) => void (tabs.current[i] = el)}
role="tab"
id={`${id}-tab-${i}`}
aria-selected={on === i}
aria-controls={`${id}-panel-${i}`}
tabIndex={on === i ? 0 : -1}
onClick={() => setOn(i)}
onKeyDown={(e) => key(e, i)}
className={cn('min-h-11 cursor-pointer rounded-lg text-[14px] font-medium transition-[background-color,color,box-shadow] duration-300', on === i ? 'bg-background text-foreground shadow-[var(--shadow-sm)]' : 'text-muted-foreground hover:text-foreground', focus)}
>
{pn.label ?? pn.title}
</button>
))}
</div>
<div className="flex flex-col gap-4 lg:min-h-[34rem] lg:flex-row">
{p.panels.map((pn, i) => {
const open = on === i;
return (
<div
key={i}
id={`${id}-panel-${i}`}
role="tabpanel"
aria-labelledby={`${id}-tab-${i}`}
onMouseEnter={() => setOn(i)}
onFocus={() => setOn(i)}
className={cn(
'group relative overflow-hidden rounded-2xl border bg-card shadow-[var(--highlight),var(--shadow-sm)] transition-[flex-grow,border-color] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none lg:flex lg:basis-0',
open ? 'border-border-strong lg:grow-[1.7]' : 'border-border max-lg:hidden lg:grow',
)}
>
<div data-clip-ok className={cn('relative aspect-[16/10] overflow-hidden bg-muted transition-opacity duration-700 lg:absolute lg:inset-0 lg:aspect-auto', fillImg, open ? 'lg:opacity-100' : 'lg:opacity-60')}>
{pn.media}
</div>
<div aria-hidden className="pointer-events-none absolute inset-0 hidden bg-[linear-gradient(to_top,rgb(0_0_0/0.92),rgb(0_0_0/0.72)_50%,rgb(0_0_0/0.3))] lg:block" />
<div className="relative flex flex-col justify-end p-6 sm:p-8 lg:w-full lg:text-white">
{pn.eyebrow && <p className="text-[13px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))] lg:text-white/80">{pn.eyebrow}</p>}
<h3 className="mt-2 text-balance text-[clamp(1.5rem,1.25rem+1vw,2.125rem)] font-semibold leading-tight tracking-[-0.03em]">{pn.title}</h3>
<div className={cn('grid transition-[grid-template-rows,opacity] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none', open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0')}>
<div className={cn('min-h-0 overflow-hidden', !open && 'invisible')}>
{pn.text && <p className="mt-4 max-w-[30rem] text-pretty text-[16px] leading-relaxed text-muted-foreground lg:text-white/85">{pn.text}</p>}
<Points points={pn.points} className="mt-5 lg:[&_li]:text-white lg:[&_li>span]:bg-white/15 lg:[&_li>span]:text-white" />
{pn.action && (
<div className="mt-7">
<Button href={pn.action.href} icon="arrow-right" className="max-sm:w-full lg:bg-white lg:text-black lg:hover:bg-white/90">
{pn.action.label}
</Button>
</div>
)}
</div>
</div>
{!open && (
<button type="button" onClick={() => setOn(i)} className={cn('mt-4 hidden min-h-11 w-fit cursor-pointer items-center gap-2 rounded-lg border border-white/30 bg-white/10 px-4 text-[13px] font-medium text-white backdrop-blur-md transition-colors hover:bg-white/20 lg:inline-flex', focus)}>
{t.more.replace('{name}', pn.label ?? pn.title)}
<Icon name="arrow-right" size={14} />
</button>
)}
</div>
</div>
);
})}
</div>
</>
);
}
function Contrast({ p }: { p: SplitScreenProps }) {
return (
<div className="grid grid-cols-[minmax(0,1fr)] md:grid-cols-2">
{p.panels.slice(0, 2).map((pn, i) => (
<div key={i} className={cn('group relative isolate overflow-hidden', i === 1 ? 'dark bg-background text-foreground' : 'bg-surface text-foreground')}>
<div className="flex h-full flex-col gap-12 px-6 py-16 sm:px-10 sm:py-20 lg:px-16 lg:py-24 xl:px-20">
<Reveal delay={i * 120} className="max-w-[28rem]">
{pn.eyebrow && <p className="text-[13px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">{pn.eyebrow}</p>}
<h3 className="mt-3 text-balance text-[clamp(1.75rem,1.35rem+1.5vw,2.75rem)] font-semibold leading-[1.08] tracking-[-0.035em]">{pn.title}</h3>
{pn.text && <p className="mt-5 text-pretty text-[16.5px] leading-relaxed text-muted-foreground">{pn.text}</p>}
<Points points={pn.points} className="mt-7" />
{pn.action && (
<div className="mt-9">
<Button href={pn.action.href} variant={i === 1 ? 'primary' : 'outline'} icon="arrow-right" magnetic={i === 1} className="max-sm:w-full">
{pn.action.label}
</Button>
</div>
)}
</Reveal>
{pn.media && (
<div data-clip-ok className={cn('relative aspect-[16/10] overflow-hidden rounded-xl border border-border shadow-[var(--shadow-lg)] md:mt-auto', fillImg)}>
<div className={cn('absolute inset-0 transition-transform duration-700 ease-[var(--ease-kit)] group-hover:scale-[1.04] motion-reduce:transition-none', fillImg)}>{pn.media}</div>
</div>
)}
</div>
</div>
))}
</div>
);
}
function EitherOr({ p, t }: { p: SplitScreenProps; t: Texts }) {
const [a, b] = p.panels;
const card = (pn: SplitPanel | undefined, i: number) =>
pn && (
<Reveal delay={i * 120} className="flex">
<article className={cn('flex w-full flex-col rounded-2xl border bg-card p-6 shadow-[var(--highlight),var(--shadow-sm)] transition-[transform,box-shadow,border-color] duration-300 hover:-translate-y-1 hover:shadow-[var(--highlight),var(--shadow-lg)] motion-reduce:hover:translate-y-0 sm:p-8', i === 1 ? 'border-[color-mix(in_oklab,var(--primary)_40%,var(--border))]' : 'border-border')}>
{pn.media && <div data-clip-ok className={cn('relative -mx-2 -mt-2 mb-7 aspect-[16/9] overflow-hidden rounded-xl border border-border bg-muted sm:-mx-4 sm:-mt-4', fillImg)}>{pn.media}</div>}
{pn.eyebrow && <p className="text-[13px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">{pn.eyebrow}</p>}
<h3 className="mt-2 text-balance text-[clamp(1.375rem,1.2rem+0.7vw,1.75rem)] font-semibold leading-tight tracking-[-0.025em] text-foreground">{pn.title}</h3>
{pn.text && <p className="mt-3 text-pretty text-[15.5px] leading-relaxed text-muted-foreground">{pn.text}</p>}
<Points points={pn.points} className="mb-8 mt-6" />
{pn.action && (
<div className="mt-auto">
<Button href={pn.action.href} variant={i === 1 ? 'primary' : 'outline'} icon="arrow-right" className="w-full">
{pn.action.label}
</Button>
</div>
)}
</article>
</Reveal>
);
return (
<>
<Head p={p} center />
<div className="relative mx-auto grid max-w-[64rem] grid-cols-[minmax(0,1fr)] gap-14 md:grid-cols-2 md:gap-10 lg:gap-16">
{card(a, 0)}
{/* the round "or" between the two, on the gap */}
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-1/2 flex -translate-y-1/2 items-center justify-center md:inset-x-auto md:inset-y-0 md:left-1/2 md:top-0 md:-translate-x-1/2 md:translate-y-0 md:flex-col">
<span className="hidden h-full w-px bg-[linear-gradient(transparent,var(--border-strong)_20%,var(--border-strong)_80%,transparent)] md:block" />
<span className="absolute flex size-12 items-center justify-center rounded-full border border-border-strong bg-background text-[13px] font-semibold text-foreground shadow-[var(--shadow-md)] md:top-1/2 md:-translate-y-1/2">{t.or}</span>
</div>
{card(b, 1)}
</div>
</>
);
}
/**
* Two halves: a sticky picture with scrolling panels, a switch between two
* offers, a light and a dark half, or two choices with an "or" between.
* <SplitScreen variant="switch" panels={[{ label: 'Private', title, text, points, action, media }, { … }]} />
*/
export function SplitScreen(props: SplitScreenProps) {
const { variant = 'halves', className, labels } = props;
const t = useTexts(TEXTS, labels);
if (variant === 'halves')
return (
<section className={cn('relative border-y border-border', className)}>
<Halves p={props} />
</section>
);
if (variant === 'contrast')
return (
<section className={cn('relative', className)}>
{(props.title || props.eyebrow) && (
<div className="kit-container pt-20 sm:pt-24">
<Head p={props} />
</div>
)}
<Contrast p={props} />
</section>
);
return <Section className={className}>{variant === 'switch' ? <Switch p={props} t={t} /> : <EitherOr p={props} t={t} />}</Section>;
}Häufige Fragen
Wie installiere ich Geteilte Fläche?
Mit aiw add @ai-website/split-screen. Der Befehl legt die Datei components/ui/split-screen.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, section, reveal, motion und button. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Geteilte Fläche?
4 Varianten, Klebende Hälfte, Umschalter, Hell und dunkel und Entweder oder. Du wählst sie mit der Prop variant, zum Beispiel variant="switch".
Kann ich die Texte von Geteilte Fläche übersetzen?
Ja. Geteilte Fläche bringt 3 eigene Texte auf Deutsch und Englisch mit, etwa „oder“ und „Seite wählen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Geteilte Fläche einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Geteilte Fläche mit aiw_components und installieren sie mit aiw_add @ai-website/split-screen. Nenn im Prompt die Variante, etwa variant="halves", dann schreibt der Assistent deine Inhalte direkt in die Props.