Hand of cards

Pick your hand

No dependencies
Code format
React + Tailwind
import { useEffect, useRef, useState, type CSSProperties } from 'react';

type Suit = 'club' | 'diamond' | 'heart' | 'spade';
const PIPS: Record<Suit, string> = {
  spade: 'M12 2 C15 7 21 10 21 15 C21 18 18.6 20 16 19.4 C15 19.2 14 18.6 13.3 17.6 C13.5 19.4 14.2 21 15.5 22 L8.5 22 C9.8 21 10.5 19.4 10.7 17.6 C10 18.6 9 19.2 8 19.4 C5.4 20 3 18 3 15 C3 10 9 7 12 2 Z',
  heart: 'M12 21.5 C6 16 2.5 12.6 2.5 8.4 C2.5 5.2 5 3 7.8 3 C9.6 3 11.2 4 12 5.6 C12.8 4 14.4 3 16.2 3 C19 3 21.5 5.2 21.5 8.4 C21.5 12.6 18 16 12 21.5 Z',
  diamond: 'M12 2 L20 12 L12 22 L4 12 Z',
  club: 'M12 2.5 A5 5 0 0 1 16.6 9.5 A5 5 0 1 1 13.4 17.6 C13.6 19.4 14.2 21 15.5 22 L8.5 22 C9.8 21 10.4 19.4 10.6 17.6 A5 5 0 1 1 7.4 9.5 A5 5 0 0 1 12 2.5 Z',
};
// Where the pips sit on a real card, in percent of the card.
const LAYOUT: Record<string, [number, number][]> = {
  '2': [[50, 24], [50, 76]],
  '7': [[32, 22], [68, 22], [50, 36], [32, 50], [68, 50], [32, 78], [68, 78]],
  '10': [[32, 19], [68, 19], [50, 30], [32, 41], [68, 41], [32, 59], [68, 59], [50, 70], [32, 81], [68, 81]],
};
const CARDS: { rank: string; suit: Suit; word: string; plan: string; price: string; note: string }[] = [
  { rank: '2', suit: 'club', word: 'Two of clubs', plan: 'Free', price: '$0', note: 'One project' },
  { rank: '7', suit: 'diamond', word: 'Seven of diamonds', plan: 'Solo', price: '$8/mo', note: 'Ten projects' },
  { rank: '10', suit: 'heart', word: 'Ten of hearts', plan: 'Team', price: '$24/mo', note: 'Five seats' },
  { rank: 'K', suit: 'spade', word: 'King of spades', plan: 'Studio', price: '$64/mo', note: 'Unlimited seats' },
  { rank: 'A', suit: 'spade', word: 'Ace of spades', plan: 'Forever', price: '$480 once', note: 'Yours for life' },
];

const css = `
  .card-hand { flex: none; --face: light-dark(#fbfcfd, #f1f2f3); --black: light-dark(#373a40, #121417); --red: color-mix(in oklab, var(--black) 72%, var(--face)); position: relative; width: 270px; height: 226px; }
  .card-hand__say { position: absolute; left: 0; right: 0; top: 2px; margin: 0; color: light-dark(#50535b, #c6c8cd); font-size: 13px; text-align: center; transition: opacity 0.2s; }
  .card-hand__say b { color: light-dark(#373a40, #f1f2f3); font-weight: 700; }
  .card-hand[data-played='true'] .card-hand__say { opacity: 0; }
  .card-hand__card { --a: 0deg; position: absolute; left: 50%; bottom: 34px; width: 84px; height: 118px; margin-left: -42px; padding: 0; border: 0; border-radius: 8px; background: var(--face); color: var(--ink); font: inherit; cursor: pointer; box-shadow: -2px 2px 7px light-dark(rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.32)), 0 0 0 1px light-dark(rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0.1)); transform-origin: 50% 100%; transform: translate(var(--x), var(--y)) rotate(var(--a)); transition: transform 0.42s cubic-bezier(0.3, 1.25, 0.5, 1); }
  .card-hand__card:focus-visible { outline: 2px solid light-dark(#373a40, #f1f2f3); outline-offset: 2px; }
  .card-hand__corner { position: absolute; display: flex; flex-direction: column; align-items: center; width: 16px; font: 700 15px/1 Georgia, 'Times New Roman', serif; letter-spacing: -0.05em; }
  .card-hand__corner svg { width: 11px; height: 11px; margin-top: 1px; fill: currentColor; }
  .card-hand__corner--a { left: 5px; top: 6px; }
  .card-hand__corner--b { right: 5px; bottom: 6px; transform: rotate(180deg); }
  .card-hand__pip { position: absolute; width: 15px; height: 15px; margin: -7.5px; fill: currentColor; }
  .card-hand__pip--low { transform: rotate(180deg); }
  .card-hand__big { position: absolute; left: 50%; top: 50%; fill: currentColor; }
  .card-hand__big--ace { width: 44px; height: 44px; margin: -22px; }
  .card-hand__big--king { display: block; font: 700 54px/1 Georgia, 'Times New Roman', serif; transform: translate(-50%, -50%); }
  .card-hand__panel { position: absolute; left: 8px; right: 8px; top: 50%; display: grid; gap: 1px; text-align: center; font-family: Georgia, 'Times New Roman', serif; opacity: 0; transform: translateY(-50%); pointer-events: none; transition: opacity 0.22s; }
  .card-hand__panel b { font-size: 12px; font-weight: 700; }
  .card-hand__panel strong { font-size: 12px; font-weight: 700; white-space: nowrap; }
  .card-hand__panel i { font-size: 6.5px; font-style: italic; }
  .card-hand__card[aria-pressed='true'] .card-hand__panel { opacity: 1; transition-delay: 0.2s; }
  .card-hand__card[aria-pressed='true'] .card-hand__pip, .card-hand__card[aria-pressed='true'] .card-hand__big { opacity: 0.08; transition: opacity 0.2s; }
  @media (prefers-reduced-motion: reduce) {
    .card-hand__card { transition: none; }
  }
`;

function Pip({ suit, className, style }: { suit: Suit; className: string; style?: CSSProperties }) {
  return (
    <svg className={className} viewBox="0 0 24 24" style={style} aria-hidden="true">
      <path d={PIPS[suit]} />
    </svg>
  );
}

export function HandCard() {
  const root = useRef<HTMLDivElement>(null);
  const [dealt, setDealt] = useState(false);
  const [hover, setHover] = useState<number | null>(null);
  const [played, setPlayed] = useState<number | null>(null);

  // The fan opens the first time it scrolls into view.
  useEffect(() => {
    const el = root.current;
    if (!el) return;
    const io = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) {
        window.setTimeout(() => setDealt(true), 200);
        io.disconnect();
      }
    });
    io.observe(el);
    return () => io.disconnect();
  }, []);

  const pose = (i: number): CSSProperties => {
    const mid = (CARDS.length - 1) / 2;
    if (played === i) return { '--x': '0px', '--y': '-16px', '--a': '0deg', scale: '1.3', zIndex: 20, transformOrigin: '50% 100%' } as CSSProperties;
    if (!dealt) return { '--x': '0px', '--y': `${-i * 0.7}px`, '--a': '0deg', transitionDelay: '0ms' } as CSSProperties;
    // Rest in an arc; the neighbours of a raised card lean away to show it.
    let a = (i - mid) * 12;
    if (hover !== null && played === null && i !== hover) a += (i < hover ? -1 : 1) * 7;
    const rad = (a * Math.PI) / 180;
    const lift = played === null && hover === i ? 22 : 0;
    const drop = played !== null ? 74 : 0;
    return {
      '--x': `${(Math.sin(rad) * (120 + lift)).toFixed(1)}px`,
      '--y': `${((1 - Math.cos(rad)) * 120 - lift + drop).toFixed(1)}px`,
      '--a': `${(hover === i && played === null ? a * 0.72 : a).toFixed(1)}deg`,
      zIndex: i + 1,
      transitionDelay: dealt && hover === null && played === null ? `${i * 40}ms` : '0ms',
    } as CSSProperties;
  };

  const shown = hover ?? null;
  return (
    <>
      <style>
        {css}
      </style>
      <div ref={root} className="card-hand" data-played={played !== null} onPointerLeave={() => setHover(null)}>
        <p className="card-hand__say" aria-live="polite">
          {shown === null ? (
            'Pick your hand'
          ) : (
            <>
              <b>{CARDS[shown].plan}</b> · {CARDS[shown].price}
            </>
          )}
        </p>
        {CARDS.map((c, i) => (
          <button
            key={c.word}
            type="button"
            className="card-hand__card"
            style={{ ...pose(i), color: c.suit === 'heart' || c.suit === 'diamond' ? 'var(--red)' : 'var(--black)' }}
            aria-label={`${c.word}: ${c.plan} plan, ${c.price}`}
            aria-pressed={played === i}
            onPointerEnter={() => setHover(i)}
            onFocus={() => setHover(i)}
            onBlur={() => setHover(null)}
            onClick={() => setPlayed(played === i ? null : i)}
          >
            <span className="card-hand__corner card-hand__corner--a" aria-hidden="true">
              {c.rank}
              <Pip suit={c.suit} className="" />
            </span>
            <span className="card-hand__corner card-hand__corner--b" aria-hidden="true">
              {c.rank}
              <Pip suit={c.suit} className="" />
            </span>
            {LAYOUT[c.rank]?.map(([x, y]) => (
              <Pip key={`${x}${y}`} suit={c.suit} className={`card-hand__pip${y > 50 ? ' card-hand__pip--low' : ''}`} style={{ left: `${x}%`, top: `${y}%` }} />
            ))}
            {c.rank === 'A' && <Pip suit={c.suit} className="card-hand__big card-hand__big--ace" />}
            {c.rank === 'K' && (
              <span className="card-hand__big card-hand__big--king" aria-hidden="true">
                K
              </span>
            )}
            <span className="card-hand__panel" aria-hidden="true" style={{ color: 'var(--black)' }}>
              <b>{c.plan}</b>
              <strong>{c.price}</strong>
              <i>{c.note}</i>
            </span>
          </button>
        ))}
      </div>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/card-hand
Source on GitHub

About this component

Pricing as an actual hand of playing cards, from the two of clubs (Free) to the ace of spades (Forever). They deal into a fan, rise under your cursor, and the one you pick is played to the table.

Dependencies
None
Difficulty
Intermediate
Triggers
card, hover, click, delay stagger, spring
Techniques
css variables, css transition, transition delay stagger, svg
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

Each card is a button with aria-pressed and an aria-label giving the card, plan and price, and a live line above repeats the plan under the pointer or focus. Under prefers-reduced-motion the cards jump to their places.

More cards

See all

Your profile6 of 9

Next: Add a photo

Jigsaw
PSRW
API keysFour on the ring. Pick one.
Keyring
Ana Kerr, Art director, 020 7946 0112
Rolodex

Reading list · 6 books

Bookshelf