Zum Inhalt springen
UI Kit
Accent

Split Screen Layout

Eine Fläche in zwei Hälften. Klebendes Bild neben scrollenden Panels, Umschalter zwischen zwei Angeboten, hell und dunkel, entweder oder.

4 VariantsEine von 5 Components in Layouts

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

Klebende HälfteNeu in 1.2

Die Bildhälfte bleibt stehen, während die Texthälfte scrollt, das Bild wechselt mit jedem Panel.

UmschalterNeu in 1.2

Zwei Angebote nebeneinander, das gewählte öffnet sich bei Zeiger oder Fokus, auf dem Telefon ein Umschalter.

Hell und dunkelNeu in 1.2

Eine helle und eine dunkle Hälfte von Rand zu Rand, jede mit eigenem Text, Bild und Aktion.

Entweder oderNeu in 1.2

Zwei Wege als Karten mit einem runden oder dazwischen, der empfohlene im Akzent.

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
Bringt mit
utilskiticonsectionrevealmotionbutton
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 3 eigene Texte
Studio
Im Code anpassen

Props

Die Schnittstelle SplitScreenProps aus components/ui/split-screen.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    SplitScreenVariant

    halvesswitchcontrasteither-or

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • panelsPflicht
    SplitPanel[]
  • reverseoptional
    boolean

    halves: 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
orEnglisch orDeutsch oder
chooseEnglisch Choose a sideDeutsch Seite wählen
moreEnglisch 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
components/ui/split-screen.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert