Split screen layout
A screen in two halves. A sticky picture beside scrolling panels, a switch between two offers, light and dark, or either or.
Two halves, one decision
A split screen divides the view into two halves that work together, a picture and its story or two offers side by side. On phones each version gets its own layout, so both halves stay clear.
Sticky half keeps the picture in place while the text half scrolls, and the picture changes with each panel. Switch places two offers side by side, the chosen one opens wide on hover or focus, on phones it becomes a switch. Light and dark sets a light and a dark half edge to edge, each with its own text, picture and action. Either or shows two choices as cards with a round or between them, the recommended one in the accent.
Use the sticky half for a story with three to five steps. Switch and either or suit two audiences, for example private and business customers. Light and dark carries contrasts, before and after or day and night.
Variants
Details
What Split screen brings along, read from the registry and the source.
- Category
- Layouts
- Area
- Layout
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/split-screen.tsx
- npm packages
- None
- Languages
- English and German, 3 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The SplitScreenProps interface from components/ui/split-screen.tsx, with the comments from the code.
variantoptionalSplitScreenVarianthalvesswitchcontrasteither-or
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodepanelsrequiredSplitPanel[]reverseoptionalbooleanhalves: the picture half on the right
Built-in texts
Split screen writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 3 texts
| Key | English | German |
|---|---|---|
| or | English or | German oder |
| choose | English Choose a side | German Seite wählen |
| more | English Show details for {name} | German Details zu {name} zeigen |
Accessibility
Read from the source of the component.
- Split screen handles the keys Arrow left, Arrow right, Home, End and Space itself.
- Sets aria-controls, aria-label, aria-labelledby and aria-selected, so screen readers announce purpose and state.
- Uses the roles tab, tablist and tabpanel.
- Shows a visible focus ring when used with the keyboard.
- 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).
- Moves with the scroll, its progress follows the position on the page.
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 5 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 343 lines
'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>;
}Frequent questions
How do I install Split screen?
With aiw add @ai-website/split-screen. The command puts components/ui/split-screen.tsx into your project, the code is yours from then on. It brings along icon, section, reveal, motion and button. It needs no further npm packages.
Which variants does Split screen have?
4 variants, Sticky half, Switch, Light and dark and Either or. You pick one with the variant prop, for example variant="switch".
Can I translate the texts of Split screen?
Yes. Split screen carries 3 texts of its own in English and German, such as “or” and “Choose a side”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Split screen?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Split screen with aiw_components and install it with aiw_add @ai-website/split-screen. Name the variant in your prompt, for example variant="halves", and the assistant writes your content straight into the props.