Flip Cards als Wendekarten
Karten mit Vorder- und Rückseite, die sich in 3D umdrehen, per Hover, Klick, Tippen oder Tastatur. Die verdeckte Seite ist inert, beide Seiten teilen sich eine Zelle, nichts springt, und bei reduzierter Bewegung wird überblendet.
Zwei Seiten, eine ruhige Karte
Eine Wendekarte hält die Vorderseite kurz und legt die Details auf die Rückseite. Diese drehen sich in 3D per Hover, Klick, Tippen oder Tastatur, und die verdeckte Seite ist inert, Screenreader und Tabulator treffen also nur die sichtbare Seite. Beide Seiten teilen sich eine Zelle, das Raster drumherum bleibt still, bei reduzierter Bewegung wird überblendet.
„Team zum Umdrehen“ dreht Porträts zu einer kurzen Bio mit Kontaktlink. „Mythos oder Fakt“ dreht Fragen zu ihrer Antwort, markiert als Mythos oder Fakt, gut für Einwände oder ein Quiz. „Produkte mit Details“ wendet Bild, Name und Preis zu Eckdaten und Knopf. „Zahlen, die hochklappen“ klappen nach oben wie eine Bahnhofstafel und zeigen, was hinter einer Zahl steht. Der „Gutschein“ dreht einen großen Gutschein zu Code und drei Schritten zum Einlösen.
Schreib die Vorderseite als Frage oder Aufhänger, die Rückseite als Antwort. Halte beide Seiten ähnlich lang, dann wirken die Karten gleichmäßig. Am Handy dreht ein Tippen die Karte, zeig also deutlich, dass es eine Rückseite gibt.
Variants
Details
Was Wendekarten mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- 3D-Flächen
- Bereich
- Visuals
- Variants
- 5
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/flip-card.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 7 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle FlipCardsProps aus components/ui/flip-card.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von Omit<React.HTMLAttributes<HTMLElement>, 'title'> an.
variantoptionalFlipCardsVariantteamfactsproductstatsgift
eyebrowoptionalstringtitleoptionalReact.ReactNodedescriptionoptionalstringitemsPflichtFlipItem[]mediaoptionalReact.ReactNode[]team: portraits; product: pictures; gift: the design on the front
actionoptional{ label: string; href?: string }product, gift: the button on the back
triggeroptional'hover' | 'click'Override how the cards turn
Eingebaute Texte
Diese Texte schreibt Wendekarten selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 7 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| turn | Englisch Turn over | Deutsch Umdrehen |
| turnBack | Englisch Turn back | Deutsch Zurückdrehen |
| myth | Englisch Myth | Deutsch Mythos |
| fact | Englisch Fact | Deutsch Stimmt |
| redeem | Englisch How to redeem | Deutsch So löst du ein |
| code | Englisch Code | Deutsch Code |
| Englisch Write an email | Deutsch E-Mail schreiben |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Wendekarten reagiert selbst auf die Tasten Escape.
- Setzt aria-expanded, damit Screenreader Zweck und Zustand ansagen.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- 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).
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 377 Zeilen
'use client';
import { useEffect, useState } from 'react';
import { cn, prefersReducedMotion, useTexts } from '@/lib/kit';
import { Eyebrow } from '@/components/ui/section';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger } from '@/components/ui/motion';
import { ease3d } from '@/components/ui/depth';
/*
* Cards with a front and a back that turn over in 3D. A card turns on hover (mouse and pen), on a
* click or tap, and with Enter or Space on its turn button; Escape turns it back. The hidden side is
* inert, so the keyboard and screen readers only meet the side in view. Both sides share one grid
* cell, the card is as tall as its taller side and nothing below jumps.
*
* With reduced motion the sides cross fade instead of turning. Without JavaScript a hover card still
* turns on hover, a click card shows its front with the back in the document for screen readers.
*
* team portraits that turn to a short bio and a contact link
* facts questions that turn to their answer, myth or fact
* product a product picture that turns to its details and a button
* stats numbers that flip upward to what is behind them
* gift one large voucher, the front a design, the back the code and how to redeem it
*
* <FlipCard front={…} back={…} trigger="click" axis="x" label="Revenue" /> a single card
*/
export type FlipCardsVariant = 'team' | 'facts' | 'product' | 'stats' | 'gift';
const TEXTS = {
en: { turn: 'Turn over', turnBack: 'Turn back', myth: 'Myth', fact: 'Fact', redeem: 'How to redeem', code: 'Code', mail: 'Write an email' },
de: { turn: 'Umdrehen', turnBack: 'Zurückdrehen', myth: 'Mythos', fact: 'Stimmt', redeem: 'So löst du ein', code: 'Code', mail: 'E-Mail schreiben' },
};
/* ------------------------------------------------------------------ */
/* The single card */
/* ------------------------------------------------------------------ */
export interface FlipCardProps {
front: React.ReactNode;
back: React.ReactNode;
/** hover turns on mouse over and on click, click only on click. Default hover. */
trigger?: 'hover' | 'click';
/** y turns sideways, x flips upward. Default y. */
axis?: 'x' | 'y';
/** Name of the card for the turn button, e.g. the person or the question */
label: string;
/** Start on the back */
defaultFlipped?: boolean;
/** Classes of both faces (radius, border, background) */
faceClassName?: string;
className?: string;
labels?: Partial<(typeof TEXTS)['en']>;
}
const FOCUS = 'focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-solid focus-visible:outline-primary';
/** A card with two sides that turns over in 3D. */
export function FlipCard({ front, back, trigger = 'hover', axis = 'y', label, defaultFlipped = false, faceClassName, className, labels }: FlipCardProps) {
const t = useTexts(TEXTS, labels);
const [flipped, setFlipped] = useState(defaultFlipped);
const [ready, setReady] = useState(false);
const [fade, setFade] = useState(false);
useEffect(() => {
setReady(true);
setFade(prefersReducedMotion());
}, []);
const rot = axis === 'y' ? 'rotateY' : 'rotateX';
const hover = trigger === 'hover';
const enter = (e: React.PointerEvent) => hover && e.pointerType !== 'touch' && setFlipped(true);
const leave = (e: React.PointerEvent) => hover && e.pointerType !== 'touch' && setFlipped(false);
const face = cn('relative [grid-area:1/1] [backface-visibility:hidden] [-webkit-backface-visibility:hidden]', faceClassName);
// Before hydration a hover card turns with CSS alone, afterwards the state leads
const cssHover = hover && !ready ? 'group-hover/flip:[transform:var(--flip-to)]' : '';
return (
<div
className={cn('group/flip relative [perspective:1400px]', className)}
onPointerEnter={enter}
onPointerLeave={leave}
onKeyDown={(e) => {
if (e.key === 'Escape' && flipped) setFlipped(false);
}}
>
<div
className={cn('grid h-full [transform-style:preserve-3d] transition-transform duration-700 motion-reduce:transition-none', cssHover)}
style={{ transform: !fade && flipped ? `${rot}(180deg)` : undefined, transitionTimingFunction: ease3d, ['--flip-to' as string]: `${rot}(180deg)` }}
>
<div className={cn(face, fade ? 'transition-[opacity,visibility] duration-300' : 'transition-[visibility] duration-700', fade && flipped && 'opacity-0', ready && flipped && 'invisible')} inert={ready && flipped ? true : undefined}>
{front}
<button type="button" aria-expanded={false} onClick={() => setFlipped(true)} className={cn('absolute inset-0 z-10 cursor-pointer rounded-[inherit]', FOCUS)}>
<span className="sr-only">
{t.turn}: {label}
</span>
</button>
</div>
<div
className={cn(face, fade ? 'transition-[opacity,visibility] duration-300' : 'transition-[visibility] duration-700', fade && !flipped && 'opacity-0', ready && !flipped && 'invisible')}
style={fade ? undefined : { transform: `${rot}(180deg)` }}
inert={ready && !flipped ? true : undefined}
>
{back}
<button
type="button"
aria-expanded
onClick={() => setFlipped(false)}
className={cn('absolute right-3 top-3 z-10 flex size-11 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground sm:size-9', FOCUS)}
>
<Icon name="rotate-ccw" size={16} />
<span className="sr-only">
{t.turnBack}: {label}
</span>
</button>
</div>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
export interface FlipItem {
title: string;
/** team: role; facts: myth or fact as 'myth' | 'fact'; product: price; stats: the number */
meta?: string;
/** the text on the back */
text?: string;
/** product, gift: points on the back */
points?: string[];
icon?: IconName;
href?: string;
}
export interface FlipCardsProps extends Omit<React.HTMLAttributes<HTMLElement>, 'title'> {
variant?: FlipCardsVariant;
eyebrow?: string;
title?: React.ReactNode;
description?: string;
items: FlipItem[];
/** team: portraits; product: pictures; gift: the design on the front */
media?: React.ReactNode[];
/** product, gift: the button on the back */
action?: { label: string; href?: string };
/** Override how the cards turn */
trigger?: 'hover' | 'click';
labels?: Partial<(typeof TEXTS)['en']>;
}
const CARD = 'rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]';
function Head({ eyebrow, title, description, center = true }: Pick<FlipCardsProps, 'eyebrow' | 'title' | 'description'> & { center?: boolean }) {
if (!title) return null;
return (
<Reveal className={cn('max-w-[42rem]', center && 'mx-auto text-center')}>
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<h2 className="text-balance text-h2 font-semibold text-foreground">{title}</h2>
{description && <p className={cn('mt-4 max-w-[36rem] text-pretty text-lead text-muted-foreground', center && 'mx-auto')}>{description}</p>}
</Reveal>
);
}
/** A hint in the corner of a front side that it turns */
function TurnHint() {
return (
<span aria-hidden className="absolute right-3 top-3 flex size-8 items-center justify-center rounded-full bg-[color-mix(in_oklab,var(--card)_80%,transparent)] text-muted-foreground shadow-[var(--shadow-sm)] backdrop-blur transition-transform duration-500 group-hover/flip:rotate-180">
<Icon name="rotate-cw" size={14} />
</span>
);
}
/** Cards that turn over to show what is on their back. */
export function FlipCards({ variant = 'team', eyebrow, title, description, items, media = [], action, trigger, labels, className, ...rest }: FlipCardsProps) {
const t = useTexts(TEXTS, labels);
const pic = (i: number) => (media.length ? media[i % media.length] : null);
if (variant === 'gift') {
const it = items[0];
if (!it) return null;
return (
<section {...rest} className={cn('relative overflow-x-clip py-20 sm:py-28', className)}>
<div className="kit-container grid grid-cols-1 items-center gap-12 md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] lg:gap-20">
<div>
<Head eyebrow={eyebrow} title={title} description={description} center={false} />
{action && (
<div className="mt-8">
<Button href={action.href} icon="arrow-right" className="max-sm:w-full">
{action.label}
</Button>
</div>
)}
</div>
<FlipCard
trigger={trigger ?? 'click'}
label={it.title}
labels={labels}
className="mx-auto w-full max-w-[32rem]"
faceClassName="rounded-3xl"
front={
<div className="relative aspect-[1.586] overflow-hidden rounded-3xl bg-[color-mix(in_oklab,var(--primary)_80%,black)] text-white shadow-[var(--shadow-lg)]">
<div data-clip-ok className="absolute inset-0 opacity-70 [&>*]:size-full">{pic(0)}</div>
<span aria-hidden className="absolute inset-0 bg-[linear-gradient(160deg,color-mix(in_oklab,var(--primary)_25%,transparent),color-mix(in_oklab,var(--primary)_55%,black))]" />
<div className="relative flex h-full flex-col justify-between p-6 sm:p-8">
<p className="text-[13px] font-medium uppercase tracking-[0.12em] opacity-85">{eyebrow}</p>
<div className="flex items-end justify-between gap-4">
<p className="text-[clamp(1.6rem,1.2rem+1.6vw,2.4rem)] font-semibold leading-[1.05] tracking-[-0.03em]">{it.title}</p>
{it.meta && <p className="shrink-0 text-[clamp(1.6rem,1.2rem+1.6vw,2.4rem)] font-semibold tabular-nums">{it.meta}</p>}
</div>
</div>
<TurnHint />
</div>
}
back={
<div className="flex aspect-[1.586] flex-col rounded-3xl border border-border bg-card p-6 shadow-[var(--highlight),var(--shadow-lg)] sm:p-8">
<p className="text-[13px] text-muted-foreground">{t.code}</p>
<p className="mt-1 text-[clamp(1.2rem,1rem+1vw,1.6rem)] font-semibold tracking-[0.14em] text-foreground">{it.text}</p>
<p className="mt-auto text-[13px] font-medium text-foreground">{t.redeem}</p>
<ol className="mt-2 grid gap-1 text-[13.5px] text-muted-foreground sm:gap-1.5">
{(it.points ?? []).map((p, i) => (
<li key={p} className="flex gap-2.5">
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-[11px] font-semibold text-primary">{i + 1}</span>
<span className="min-w-0">{p}</span>
</li>
))}
</ol>
</div>
}
/>
</div>
</section>
);
}
const cols = variant === 'facts' ? 'sm:grid-cols-2 lg:grid-cols-3' : variant === 'stats' || items.length >= 4 ? 'sm:grid-cols-2 lg:grid-cols-4' : 'sm:grid-cols-2 lg:grid-cols-3';
const how = trigger ?? (variant === 'facts' || variant === 'stats' ? 'click' : 'hover');
return (
<section {...rest} className={cn('relative py-20 sm:py-28', className)}>
<div className="kit-container">
<Head eyebrow={eyebrow} title={title} description={description} />
<Stagger as="ul" className={cn('grid grid-cols-1 gap-5 lg:gap-6', title && 'mt-12 sm:mt-16', cols)}>
{items.map((it, i) => (
<li key={i} className="flex">
{variant === 'team' && (
<FlipCard
trigger={how}
label={it.title}
labels={labels}
className="w-full"
faceClassName="rounded-2xl"
front={
<div className={cn(CARD, 'h-full p-3')}>
<div className="aspect-[4/5] overflow-hidden rounded-xl bg-surface [&>*]:size-full">{pic(i)}</div>
<div className="px-2 pb-1 pt-4">
<h3 className="text-[17px] font-semibold text-foreground">{it.title}</h3>
{it.meta && <p className="text-[14px] text-muted-foreground">{it.meta}</p>}
</div>
<TurnHint />
</div>
}
back={
<div className={cn(CARD, 'flex h-full flex-col bg-[color-mix(in_oklab,var(--primary)_7%,var(--card))] p-6')}>
<p className="pr-10 text-[17px] font-semibold text-foreground">{it.title}</p>
{it.meta && <p className="text-[14px] text-primary">{it.meta}</p>}
<p className="mt-4 text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{it.text}</p>
{it.href && (
<a href={it.href} className={cn('mt-auto inline-flex min-h-11 cursor-pointer items-center gap-2 pt-6 text-[14px] font-medium text-foreground hover:text-primary', FOCUS)}>
<Icon name="mail" size={16} />
{t.mail}
</a>
)}
</div>
}
/>
)}
{variant === 'facts' && (
<FlipCard
trigger={how}
label={it.title}
labels={labels}
className="w-full"
faceClassName="rounded-2xl"
front={
<div className={cn(CARD, 'flex h-full min-h-[13rem] flex-col p-6 sm:p-7')}>
<span className="flex size-10 items-center justify-center rounded-xl bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-primary">
<Icon name={it.icon ?? 'help-circle'} size={19} />
</span>
<p className="mt-auto pt-6 text-balance text-[18px] font-semibold leading-snug text-foreground">{it.title}</p>
<TurnHint />
</div>
}
back={
<div className={cn(CARD, 'flex h-full min-h-[13rem] flex-col p-6 sm:p-7', it.meta === 'fact' ? 'bg-[color-mix(in_oklab,var(--success)_9%,var(--card))]' : 'bg-[color-mix(in_oklab,var(--primary)_7%,var(--card))]')}>
<span className={cn('inline-flex w-fit items-center gap-1.5 rounded-md px-2 py-1 text-[12.5px] font-semibold', it.meta === 'fact' ? 'bg-[color-mix(in_oklab,var(--success)_16%,var(--card))] text-success-foreground' : 'bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-primary')}>
<Icon name={it.meta === 'fact' ? 'check' : 'x'} size={13} />
{it.meta === 'fact' ? t.fact : t.myth}
</span>
<p className="mt-4 text-pretty pr-6 text-[15px] leading-relaxed text-foreground">{it.text}</p>
</div>
}
/>
)}
{variant === 'product' && (
<FlipCard
trigger={how}
label={it.title}
labels={labels}
className="w-full"
faceClassName="rounded-2xl"
front={
<div className={cn(CARD, 'flex h-full flex-col p-3')}>
<div className="aspect-square overflow-hidden rounded-xl bg-surface [&>*]:size-full">{pic(i)}</div>
<div className="flex items-baseline justify-between gap-3 px-2 pb-2 pt-4">
<h3 className="text-[16px] font-semibold text-foreground">{it.title}</h3>
{it.meta && <span className="shrink-0 text-[15px] font-semibold tabular-nums text-foreground">{it.meta}</span>}
</div>
<TurnHint />
</div>
}
back={
<div className={cn(CARD, 'flex h-full flex-col p-6')}>
<p className="pr-10 text-[16px] font-semibold text-foreground">{it.title}</p>
{it.text && <p className="mt-2 text-[14px] leading-relaxed text-muted-foreground">{it.text}</p>}
<dl className="mt-5 grid gap-2 border-t border-border pt-4 text-[13.5px]">
{(it.points ?? []).map((p) => {
const [k, v] = p.split('|');
return (
<div key={p} className="flex justify-between gap-3">
<dt className="text-muted-foreground">{k}</dt>
<dd className="text-right font-medium text-foreground">{v}</dd>
</div>
);
})}
</dl>
<div className="mt-auto pt-6">
<Button href={it.href ?? action?.href} size="sm" className="w-full" iconLeft="cart">
{action?.label ?? it.title}
</Button>
</div>
</div>
}
/>
)}
{variant === 'stats' && (
<FlipCard
trigger={how}
axis="x"
label={it.title}
labels={labels}
className="w-full"
faceClassName="rounded-2xl"
front={
<div className={cn(CARD, 'flex h-full min-h-[11.5rem] flex-col justify-between p-6')}>
<p className="text-[14px] text-muted-foreground">{it.title}</p>
<p className="text-[clamp(2.4rem,1.8rem+2vw,3.4rem)] font-semibold leading-none tracking-[-0.045em] tabular-nums text-foreground">{it.meta}</p>
<TurnHint />
</div>
}
back={
<div className={cn(CARD, 'flex h-full min-h-[11.5rem] flex-col bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))] p-6')}>
<p className="flex items-center gap-2 pr-10 text-[14px] font-medium text-primary">
<Icon name={it.icon ?? 'info'} size={15} />
{it.title}
</p>
<p className="mt-3 text-pretty text-[14.5px] leading-relaxed text-foreground">{it.text}</p>
</div>
}
/>
)}
</li>
))}
</Stagger>
</div>
</section>
);
}Häufige Fragen
Wie installiere ich Wendekarten?
Mit aiw add @ai-website/flip-card. Der Befehl legt die Datei components/ui/flip-card.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind section, button, icon, reveal, motion und depth. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Wendekarten?
5 Varianten, Team zum Umdrehen, Mythos oder Fakt, Produkte mit Details, Zahlen, die hochklappen und Gutschein. Du wählst sie mit der Prop variant, zum Beispiel variant="facts".
Kann ich die Texte von Wendekarten übersetzen?
Ja. Wendekarten bringt 7 eigene Texte auf Deutsch und Englisch mit, etwa „Umdrehen“, „Zurückdrehen“ und „Mythos“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Wendekarten einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Wendekarten mit aiw_components und installieren sie mit aiw_add @ai-website/flip-card. Nenn im Prompt die Variante, etwa variant="team", dann schreibt der Assistent deine Inhalte direkt in die Props.