FlipCard

Two faces of one card, and a turn between them.

For content that is genuinely two-sided — a bank card and its security code, a term and its definition, a photograph and what it is of.

One box, and both faces in it. State the height on the root and both faces take it; leave it off and the front's own content decides, with the back given whatever that came to. A back with more in it than the front overflows rather than growing the card — a card whose height changed halfway through the turn would move everything under it.

It is not for progressive disclosure. The back replaces the front rather than extending it, so anything the reader needs to compare across the turn belongs in Collapsible or a second card.

Installation

FlipCard ships with the library — no separate install.

import { FlipCard, Avatar, Badge, Button, Card, CheckIcon, Chip, QRCode, Rating, Text, cn } from 'panelui-native';
import { Image, View } from 'react-native';

Or copy the source into your project, to own and edit it:

npx panelui-cli@latest add flip-card

Usage

<FlipCard>
  <FlipCard.Front className="items-center justify-center rounded-2xl bg-card p-6">
    <Text>Tap me</Text>
  </FlipCard.Front>
  <FlipCard.Back className="items-center justify-center rounded-2xl bg-primary p-6">
    <Text className="text-primary-foreground">The other side</Text>
  </FlipCard.Back>
</FlipCard>

Composition

<FlipCard>
  <FlipCard.Front />
  <FlipCard.Back />
</FlipCard>
  • FlipCard.Front — See props below.
  • FlipCard.Back — See props below.

Examples

A bank card

The case the component exists for. The number, the holder and the expiry are on the front; the stripe and the security code are on the back, because that is where they are on the object — which is the whole test for whether something should be a flip card at all.

The height is on the root, so both faces are handed the same box.

The issuer's mark sits opposite the name, tinted to --color-primary-foreground rather than swapped for a light and a dark file: the face is bg-primary in every theme, and that is the token which reads against it.

<FlipCard className="h-[200px] w-full">
  <FlipCard.Front className="h-full justify-between rounded-2xl bg-primary p-5">
    <View className="flex-row items-center justify-between">
      <Text className="text-primary-foreground">PanelUI Bank</Text>
      <Image
        source={require('./assets/logo.png')}
        style={{ width: 26, height: 26, tintColor: brandMark }}
        resizeMode="contain"
        accessibilityLabel="PanelUI"
      />
    </View>
    <Text size="xl" className="tracking-[3px] text-primary-foreground">
      4242 4242 4242 4242
    </Text>
    <View className="flex-row justify-between">
      <Text size="sm" className="text-primary-foreground">K. ABDI</Text>
      <Text size="sm" className="text-primary-foreground">09/29</Text>
    </View>
  </FlipCard.Front>
  <FlipCard.Back className="h-full rounded-2xl bg-surface-secondary py-5">
    <View className="h-10 w-full bg-foreground/80" />
    <View className="mt-5 flex-row items-center justify-end gap-3 px-5">
      <Text size="sm" muted>CVC</Text>
      <View className="rounded-md bg-background px-3 py-1">
        <Text className="font-mono">829</Text>
      </View>
    </View>
  </FlipCard.Back>
</FlipCard>

A hotel room

The listing on the front — where it is, how it is rated, what it starts at — and the rooms with their prices on the back.

Nothing on the front is repeated on the back, and nothing on the back has to be compared with the front. That is the test: content that is two-sided rather than summarised and expanded. For the other shape, where the detail extends what is above it, use Collapsible.

There is no photograph, and no grey rectangle standing in for one. A placeholder with an icon in the middle reads as an image that failed to load. The coloured band carries the name instead — which is what a photograph would have had text over anyway.

<FlipCard className="h-[340px] w-full">
  <FlipCard.Front className="h-full">
    <Card className="h-full justify-between overflow-hidden bg-primary p-5">
      <View className="gap-4">
        <Badge
          variant="success"
          className="bg-success"
          labelClassName="text-success-solid-foreground"
        >
          9.1 · Excellent
        </Badge>
        <View className="gap-1">
          <Text size="2xl" weight="semibold" className="text-primary-foreground">The Wharf House</Text>
          <Text size="sm" className="text-primary-foreground/80">Old Town · 400m from the harbour</Text>
        </View>
        <Rating value={4.5} readOnly size="sm" />
      </View>

      <View className="gap-3">
        <View className="border-t border-primary-foreground/15" />
        <Text size="sm" className="text-primary-foreground/70">Free cancellation until 24 hours before</Text>
        <View className="flex-row items-baseline gap-1.5">
          <Text size="2xl" weight="semibold" className="text-primary-foreground">$180</Text>
          <Text size="sm" className="text-primary-foreground/60">a night</Text>
        </View>
      </View>
    </Card>
  </FlipCard.Front>

  <FlipCard.Back className="h-full">
    <Card className="h-full justify-between p-5">
      <Text size="lg" weight="semibold">Rooms</Text>
      <View>
        {rooms.map((room, index) => (
          <View
            key={room.name}
            className={cn(
              'flex-row items-center justify-between gap-3 py-3',
              index > 0 && 'border-t border-border'
            )}
          >
            <View className="shrink gap-0.5">
              <Text size="sm" weight="medium">{room.name}</Text>
              <Text size="xs" muted>{room.sleeps}</Text>
            </View>
            <Text size="sm" weight="semibold">{room.price}</Text>
          </View>
        ))}
      </View>
      <Button fullWidth onPress={book}>Book from $180</Button>
    </Card>
  </FlipCard.Back>
</FlipCard>

A product

A shop's grid has room for a name and a price. The fabric, the sizes left and the delivery are what decide the sale, and they are normally a page away — this puts them on the other side of the same tile.

The band at the top is the garment's colours rather than a picture of it: real information, and the one thing a photograph of a plain tee would have told you that its name does not.

direction="vertical" because a card in a grid has neighbours either side of it: a horizontal turn sweeps its near edge out over them, and a vertical one stays inside its own column.

<FlipCard direction="vertical" className="h-[340px] w-full">
  <FlipCard.Front className="h-full">
    <Card className="h-full justify-between overflow-hidden bg-primary p-5">
      <View className="flex-row items-start justify-between gap-3">
        <View className="shrink gap-1">
          <Text size="xs" weight="medium" className="text-primary-foreground/60">Bone · Slate · Moss</Text>
          <Text size="2xl" weight="semibold" className="text-primary-foreground">Heavyweight Tee</Text>
          <Text size="sm" className="text-primary-foreground/70">Organic cotton, boxy fit</Text>
        </View>
        <Badge
          variant="warning"
          className="bg-warning"
          labelClassName="text-warning-solid-foreground"
        >
          3 left
        </Badge>
      </View>

      <View className="gap-3">
        <View className="border-t border-primary-foreground/15" />
        <Text size="sm" className="text-primary-foreground/70">Ships in two days</Text>
        <Text size="2xl" weight="semibold" className="text-primary-foreground">$48</Text>
      </View>
    </Card>
  </FlipCard.Front>

  <FlipCard.Back className="h-full">
    <Card className="h-full justify-between p-5">
      <View className="gap-1">
        <Text size="lg" weight="semibold">Heavyweight Tee</Text>
        <Text size="sm" muted>280gsm combed cotton, pre-shrunk.</Text>
      </View>
      <View className="gap-2">
        <Text size="xs" weight="medium" muted>SIZE</Text>
        <View className="flex-row flex-wrap gap-2">
          {sizes.map((size) => (
            <Chip key={size.label} size="sm" disabled={size.soldOut}>{size.label}</Chip>
          ))}
        </View>
      </View>
      <Button fullWidth onPress={addToBag}>Add to bag · $48</Button>
    </Card>
  </FlipCard.Back>
</FlipCard>

An event ticket

The strongest case after a bank card, and for the same reason: the code really is on the back. A ticket is read by a person on the front and by a scanner on the back, and the two are never needed at once.

The dashed rule is where a paper ticket tears, and it is doing real work — it separates what you read walking in from what you read looking for your seat, and it says "ticket" before any of the words do.

QRCode is drawn as a bare QRCode.Canvas. Its frame, title and caption would be a second card inside the one already being turned.

<FlipCard className="h-[320px] w-full">
  <FlipCard.Front className="h-full justify-between rounded-2xl bg-primary p-6">
    <View className="gap-1">
      <Text size="xs" weight="medium" className="text-primary-foreground">SAT 14 SEPTEMBER · 20:00</Text>
      <Text size="2xl" weight="semibold" className="text-primary-foreground">Night Signal</Text>
      <Text size="sm" className="text-primary-foreground">The Foundry, Manchester</Text>
    </View>

    <View className="gap-5">
      <View className="border-t border-dashed border-primary-foreground/30" />
      <View className="flex-row justify-between">
        {fields.map((field) => (
          <View key={field.label} className="gap-1">
            <Text size="xs" className="text-primary-foreground">{field.label}</Text>
            <Text size="xl" weight="semibold" className="text-primary-foreground">{field.value}</Text>
          </View>
        ))}
      </View>
    </View>
  </FlipCard.Front>

  <FlipCard.Back className="h-full items-center justify-between rounded-2xl border border-border bg-card p-6">
    <Text size="xs" weight="medium" muted>NIGHT SIGNAL · GATE C</Text>
    <QRCode value="https://example.com/t/NS-8842-14C" size="md">
      <QRCode.Canvas />
    </QRCode>
    <Text size="sm" weight="medium" className="font-mono">NS-8842-14C</Text>
  </FlipCard.Back>
</FlipCard>

A profile

A directory is a grid of names, and everything worth knowing about somebody is on the page you have to leave the grid for. The card carries it instead.

Ranged left rather than centred. A centred avatar over a centred name in a box this size is a memorial plaque; left-aligned with the detail under it, it is a directory entry.

Size the card for the taller face. A face is given the front's box and overflows rather than growing it, so a back with more in it than the front is what decides the height.

<FlipCard className="h-[320px] w-full">
  <FlipCard.Front className="h-full">
    <Card className="h-full justify-between bg-primary p-6">
      <View className="gap-4">
        <Avatar size="lg" fallback="RM" className="border-primary-foreground/20" />
        <View className="gap-2">
          <View className="gap-1">
            <Text size="2xl" weight="semibold" className="text-primary-foreground">Rosa Marín</Text>
            <Text size="sm" className="text-primary-foreground/70">Staff Engineer · Platform</Text>
          </View>
          <Badge
            variant="success"
            className="bg-success"
            labelClassName="text-success-solid-foreground"
          >
            Available
          </Badge>
        </View>
      </View>

      <View className="flex-row">
        {stats.map((stat, index) => (
          <View
            key={stat.label}
            className={cn('flex-1 gap-0.5', index > 0 && 'border-l border-primary-foreground/20 pl-4')}
          >
            <Text size="lg" weight="semibold" className="text-primary-foreground">{stat.value}</Text>
            <Text size="xs" className="text-primary-foreground/60">{stat.label}</Text>
          </View>
        ))}
      </View>
    </Card>
  </FlipCard.Front>

  <FlipCard.Back className="h-full">
    <Card className="h-full justify-between p-6">
      <View className="gap-2">
        <Text size="lg" weight="semibold">Rosa Marín</Text>
        <Text size="sm" muted>Twelve years on build tooling.</Text>
      </View>
      <View className="flex-row flex-wrap gap-2">
        {skills.map((skill) => <Chip key={skill} size="sm">{skill}</Chip>)}
      </View>
      <View className="gap-3">
        {contacts.map((contact) => (
          <Text key={contact} size="sm">{contact}</Text>
        ))}
      </View>
    </Card>
  </FlipCard.Back>
</FlipCard>

Turning it the other way

direction="vertical" turns the card top over bottom about its horizontal axis instead of left to right.

It is the right choice when the card sits in a row of others: a horizontal turn sweeps the near edge out over its neighbours, and a vertical one stays inside the card's own column.

<FlipCard direction="vertical" className="h-[140px]">
  <FlipCard.Front className="h-full items-center justify-center rounded-2xl border border-border bg-card">
    <Text size="3xl" weight="bold">37°</Text>
    <Text size="sm" muted>Celsius</Text>
  </FlipCard.Front>
  <FlipCard.Back className="h-full items-center justify-center rounded-2xl bg-primary">
    <Text size="3xl" weight="bold" className="text-primary-foreground">99°</Text>
    <Text size="sm" className="text-primary-foreground/70">Fahrenheit</Text>
  </FlipCard.Back>
</FlipCard>

Flipped from somewhere else

Pass flipped to hold the state yourself, and trigger="none" so the card does not also turn under a tap. Use it when the control that reveals the back is not the card — a button in a toolbar, a row in a list, a switch.

const [flipped, setFlipped] = useState(false);

<View className="gap-3">
  <FlipCard flipped={flipped} trigger="none" className="h-[140px]">
    <FlipCard.Front className="h-full items-center justify-center gap-1.5 rounded-2xl border border-border bg-card px-6">
      <Text size="xs" weight="medium" muted>QUESTION</Text>
      <Text size="lg" weight="medium">Which ocean is the deepest?</Text>
    </FlipCard.Front>
    <FlipCard.Back className="h-full items-center justify-center gap-1.5 rounded-2xl bg-primary px-6">
      <Text size="xs" weight="medium" className="text-primary-foreground/60">ANSWER</Text>
      <Text size="lg" weight="medium" className="text-primary-foreground">The Pacific</Text>
    </FlipCard.Back>
  </FlipCard>
  <Button variant="outline" onPress={() => setFlipped((on) => !on)}>
    {flipped ? 'Back to the question' : 'Show the answer'}
  </Button>
</View>

Turning it with a finger

trigger="drag" turns the card with the drag and springs to whichever face is nearer when you let go. A short drag travelling fast is honoured rather than refused on a technicality.

The drag replaces the tap rather than joining it, so the card also takes an activate accessibility action — a card that can only be turned by dragging is a card some readers can never see the back of.

<FlipCard trigger="drag" className="h-[140px]">
  <FlipCard.Front className="h-full items-center justify-center rounded-2xl border border-border bg-card">
    <Text size="sm" muted>Drag across it</Text>
  </FlipCard.Front>
  <FlipCard.Back className="h-full items-center justify-center gap-2 rounded-2xl bg-primary">
    <CheckIcon size={20} color={onPrimary} />
    <Text size="sm" className="text-primary-foreground/80">Let go and it settles</Text>
  </FlipCard.Back>
</FlipCard>

API Reference

FlipCard

PropTypeDefaultDescription
directionFlipCardDirection'horizontal'Which axis the card turns about. horizontal turns it left to right about its vertical axis; vertical turns it top over bottom.
rotationFlipCardRotation'normal'Which way round the turn goes. reverse sends it the other way, for a pair of cards that should not turn identically.
flippedbooleanWhich face is showing, when the caller holds it. Leave unset to let the card keep its own, and pair with trigger="none" for a card flipped only from outside.
defaultFlippedbooleanfalseWhich face an uncontrolled card starts on.
onFlippedChange(flipped: boolean) => voidFires whenever the card settles on the other face, however it got there.
triggerFlipCardTrigger'press'What turns the card. press is a tap anywhere on it; drag turns it with the finger and springs to whichever face is nearer on release; none leaves it to flipped.
perspectivenumber1000How deep the turn looks, in points. Smaller is more dramatic — the near edge swings further out — and below about 400 a full card starts to read as a door rather than a card.
classNamestringClasses for the card's own box. The front's size is the card's size.

FlipCard.Face

PropTypeDefaultDescription
classNamestring

Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.

Notes

Both faces are hidden twice

backfaceVisibility is the mechanism, and it is not reliable on every Android surface. So the face that has turned away is also faded out and dropped behind at the halfway point, off the same value that drives the turn. Two mechanisms for one job, because the failure of the first is a card showing both faces mirrored through each other and nothing in the tree that says why.

Under reduce motion

The faces swap with no turn at all — not a shorter turn, none. The rotation is the part that moves and moving is what the setting is about; which face is showing is the information, and it is kept.

What is yours to change

perspective sets how deep the turn looks, in points. Smaller is more dramatic, because the near edge swings further out; below about 400 a full-width card starts to read as a door rather than a card. rotation="reverse" sends the turn the other way, which is worth having when two cards side by side should not turn identically.

useFlipCard() returns the settled face, the direction, and progress — a shared value running 0 to 1 across the turn — so a face can drive its own animation off the same turn rather than starting a second one beside it.

Public exports

Values: FlipCard, useFlipCard

Types: FlipCardProps, FlipCardFaceProps, FlipCardDirection, FlipCardRotation, FlipCardTrigger

On this page