Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Swipe deck

New

A phone with a deck of profile cards: drag left to pass, right to like, and the next card grows into place.

swipe-deck/swipe-deck.tsx
Live · interactive

Elias Morrow

Male, 21

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

When to use

  • A quick yes-or-no on one item at a time, on a phone: people to meet, homes to visit, outfits, recipes.
  • A deck where the picture carries the choice, and a name and a line or two are enough to decide.
  • Triage that should feel light: drag it away, or press the cross or the heart, and the next one is already there.
  • A screen held in one hand, where a thumb’s sideways drag is the fastest gesture there is.

When not to use

  • Comparing items side by side, or going back to one. Use a list or a grid with a save button.
  • Choices with more than two answers (yes, no, later, share). Use a card with labelled buttons, or an action sheet.
  • Decisions that cost something or cannot be undone, such as a payment or a deletion. Use a confirm dialog.
  • Items that need reading: long text, specs or prices. Use a detail page with the actions at its foot.
  • A desktop page. It is drawn as a 390-point phone screen; on a wide layout use a grid of cards.

You can add this component with the shadcn CLI or by hand:

Into components/swipe-deck of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/swipe-deck.json

Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

Usage

discover.tsx
import { SwipeDeck, type Profile } from './swipe-deck';const people: Profile[] = [  { first: 'Elias', last: 'Morrow', detail: 'Male, 21', interests: ['bike', 'swim'], scene: 'dusk' },  { first: 'Juno', last: 'Hale', detail: 'Female, 22', interests: ['camera'], scene: 'garden' },];export function Discover() {  return (    <SwipeDeck      profiles={people}      autoplay={false}      onDecide={(profile, choice) => console.log(choice, profile.first)}    />  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

A dating app’s discovery screen on a phone. The top card follows the finger, turning a little as it goes; its cross or heart swells out of the card into a big badge and the card washes dark or purple. Let go far enough, or flick, and it flies off on its momentum while the card behind grows into place and its name fades in; otherwise a spring brings it back. The buttons and the arrow keys do the same. Photographic portraits of generated adults, with invented names. Light and dark.

  • The phone’s screen is laid out at 390 × 844 points and scaled as a whole to fit the stage (never above its own size), so every measure inside stays in points; pointer positions are divided by the same scale.
  • A drag writes one transform on the top card each frame, outside React: it follows the finger (a third as much up and down), turns 0.024° per px and shrinks by up to 4%. React only renders which profile is on top, next and third.
  • The progress is the distance over 250 px. With it the button of that side is swapped for a badge above the deck that rises towards the free space and grows to 2.3 times (pass) or 2.7 times (like), the other button fades to a quarter, and a wash comes over the card, black for pass and purple for like. Past 250 px the badge rides along with the card.
  • The card behind waits narrower (84% wide) and peeks 11 px below the top one, showing its art alone; it grows with the progress, to 92%.
  • Letting go past 130 px, or flicking faster than 0.55 px/ms, sends the card off in a 300 ms Hermite curve that starts at the finger’s speed, while the card behind grows into place (300 ms) and its gradient, name and buttons fade in. Otherwise a critically damped spring (stiffness 500, damping 44) brings it back from the finger’s speed, stepped at 240 Hz so a slow frame cannot throw it.
  • The pass and like buttons and the arrow keys play the same moves: the card slides out to full progress in 260 ms, then flies. Once React has swapped the roles, a layout effect resets the motion before the browser paints, and moves the focus to the new top card if the old one had it.
  • While on screen and untouched, a demo finger teases the card each way, passes, taps the heart and flicks a like; any real input hands control to the reader.
  • Every colour is a light-dark() token on the root: at night the stage and the app go dark, the pass button turns charcoal, and the cards and the brand’s purple stay the same.
PropTypeDefaultDescription
profilesProfile[]four invented profilesThe deck, in order; it starts over after the last. A profile has first and last names, a detail line, up to three interests (bike, swim, hike, music, camera, book) a scene for its photograph (dusk, window, garden, shore), and an optional imageSrc for your own photo.
onDecide(profile: Profile, choice: 'pass' | 'like') => void—Called once a card has left the screen.
autoplaybooleantrueWhile on screen and untouched, a demo finger swipes the deck. Any real input hands control to the reader.
colorScheme'light' | 'dark' | 'system'—Light or dark colours, or the reader’s; without it, the page’s color-scheme.
defaultOffsetnumber—Opens with the top card held this many points to the side, as if mid-drag: negative towards pass, positive towards like, 250 at full progress. It turns the demo off.
classNamestring—Added to the stage.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

swipe-deck.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useCallback,  useEffect,  useEffectEvent,  useLayoutEffect,  useRef,  useState,  type KeyboardEvent as ReactKeyboardEvent,  type PointerEvent as ReactPointerEvent,} from 'react';import { Icon, InterestIcon, Portrait, type Interest, type Scene } from './art';import { useAutoplay } from './use-autoplay';import styles from './swipe-deck.module.css';/* * Swipe deck: a phone with a deck of profile cards. Drag the top card left to pass, right to like; * or press its buttons, or use the arrow keys. The next card waits behind, and grows into place * as the top one leaves. * * - The card follows the finger: it moves with it (a third as much up and down), turns a little
swipe-deck.module.css
/* Swipe deck (swipe-deck.tsx): a quiet stage with a phone in its middle. The phone's screen is   drawn at 390 × 844 points and scaled down as a whole (--fit, set from the stage's size) when   the stage is narrower or shorter. Every measure inside it is in the screen's points.   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes   the color-scheme it inherits from the page (light unless the page says otherwise), or the one   its colorScheme prop sets (data-color-scheme). At night the stage and the app go dark; the   cards, their art and the brand's purple stay as they are.   Memory and layout. The stage is one layout boundary. What moves is a transform written by the   script on its own layer: the top card, the card behind it, the two badges and the demo's   finger. Nothing inside an SVG ever changes. */.root {  /* The stage and its edge. */  --stage: #f0f0f5;  --line: rgb(0 0 0 / 0.04);  /* The screen, its words and icons, the idle tabs, and its faint edge. */  --screen: #fcfcfd;  --ink: #111113;  --idle: #babbbc;  --screen-edge: rgb(0 0 0 / 0.03);  /* The pass button and its cross. */  --pass: #ece9ef;
art.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';/* Full-bleed photographic portraits; the profile text, gradients and controls are * composed separately in React. Every default portrait depicts an invented adult. */export type Scene = 'dusk' | 'window' | 'garden' | 'shore';export type Interest = 'bike' | 'swim' | 'hike' | 'music' | 'camera' | 'book';/** A photo fills the card without being stretched; callers can supply their own image. */export function Portrait({  scene,  src,  className,}: {  scene: Scene;  src?: string;  className?: string;}) {  return (    <img      className={className}      src={src ?? `/experiments/swipe-deck/${scene}-photo-v1.webp`}
use-autoplay.ts
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) {  const ref = useRef<T>(null);  const [visible, setVisible] = useState(false);  const [reduced, setReduced] = useState(true);  const [taken, setTaken] = useState(false);  const timer = useRef(0);  useEffect(() => {    const element = ref.current;    if (!element) return;    let onScreen = false;    const update = () => setVisible(onScreen && !document.hidden);    const observer = new IntersectionObserver(
  • The top card is a focusable group labelled with the name and detail; Left and Right arrow keys pass or like it. Its pass and like buttons are real buttons named after the person.
  • Each decision is announced in a polite live region (“Passed on Elias Morrow. Now showing Tomas Veira.”), and focus on a leaving card moves to the one that replaces it.
  • The cards behind are hidden from assistive technology and inert; the portraits, badges, status bar and demo finger are decorative and hidden.
  • Dragging uses pointer events with capture and leaves vertical panning to the page on touch. Reduced motion skips the flights and the spring and stops the demo.