Skip to content
UI Kit
Accent color

Flip cards in 3D

Cards with a front and a back that turn over in 3D, on hover, click, tap or with the keyboard. The hidden side is inert, both sides share one cell so nothing jumps, and reduced motion cross fades.

5 variantsOne of 6 components in 3D surfaces

Two sides, one calm card

A flip card keeps the front short and puts the details on its back. These turn over in 3D on hover, click, tap or with the keyboard, and the hidden side is inert, so screen readers and the tab key only meet the side in view. Both sides share one cell, the grid around them stays still, and with reduced motion they cross fade.

Team that turns around turns portraits to a short bio and a contact link. Myth or fact turns questions to their answer, marked as myth or fact, good for objections or a quiz. Products with details turn a picture with name and price to a short spec list and the button. Numbers that flip up turn upward like a station board to what stands behind a figure. Gift voucher turns one large voucher to its code and three steps to redeem.

Write the front as a question or a hook and the back as the answer. Keep both sides about the same length so the cards look even. On phones a tap turns the card, so make it clear that there is a back.

Variants

Team that turns aroundNew in 1.2

Portraits that turn to a short bio and a contact link on hover. A tap or Enter turns them too, Escape turns them back.

Myth or factNew in 1.2

Questions that turn to their answer on a click, marked as myth or fact. Good for objections, a quiz or an FAQ that invites play.

Products with detailsNew in 1.2

A picture with name and price turns to its details, a short spec list and the button. The card keeps its size, the grid below stays still.

Numbers that flip upNew in 1.2

Big numbers that flip upward on a click to what stands behind them. The upward turn reads like a board at a station.

Gift voucherNew in 1.2

One large voucher in the accent. A click turns it to the code and three steps to redeem, the turn button stays in its corner.

Details

What Flip cards brings along, read from the registry and the source.

Category
3D surfaces
Variants
5
Version
1.0.0 · since 1.2.0
File
components/ui/flip-card.tsx
Brings along
utilskitsectionbuttoniconrevealmotiondepth
npm packages
None
Languages
English and German, 7 texts of its own
Studio
Adjust in code

Props

The FlipCardsProps interface from components/ui/flip-card.tsx, with the comments from the code. Also takes every prop of Omit<React.HTMLAttributes<HTMLElement>, 'title'>.

  • variantoptional
    FlipCardsVariant

    teamfactsproductstatsgift

  • eyebrowoptional
    string
  • titleoptional
    React.ReactNode
  • descriptionoptional
    string
  • itemsrequired
    FlipItem[]
  • mediaoptional
    React.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

Built-in texts

Flip cards writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 7 texts
turnEnglish Turn overGerman Umdrehen
turnBackEnglish Turn backGerman Zurückdrehen
mythEnglish MythGerman Mythos
factEnglish FactGerman Stimmt
redeemEnglish How to redeemGerman So löst du ein
codeEnglish CodeGerman Code
mailEnglish Write an emailGerman E-Mail schreiben

Accessibility

Read from the source of the component.

  • Flip cards handles the keys Escape itself.
  • Sets aria-expanded, so screen readers announce purpose and state.
  • Adds text for screen readers where icons and graphics stand alone.
  • 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).
  • With reduced motion (prefers-reduced-motion) it stays still. 1 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, 377 lines
components/ui/flip-card.tsx
'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>
  );
}

Frequent questions

How do I install Flip cards?

With aiw add @ai-website/flip-card. The command puts components/ui/flip-card.tsx into your project, the code is yours from then on. It brings along section, button, icon, reveal, motion and depth. It needs no further npm packages.

Which variants does Flip cards have?

5 variants, Team that turns around, Myth or fact, Products with details, Numbers that flip up and Gift voucher. You pick one with the variant prop, for example variant="facts".

Can I translate the texts of Flip cards?

Yes. Flip cards carries 7 texts of its own in English and German, such as “Turn over”, “Turn back” and “Myth”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Flip cards?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Flip cards with aiw_components and install it with aiw_add @ai-website/flip-card. Name the variant in your prompt, for example variant="team", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard