dqnamoexperiments / playing-cards

Playing Cards

A composable playing card and an interactive fan. Hover to thumb through the hand, then click or flick a card upward to play it.

Hand size
import Image from "next/image";import { cn } from "@/helpers/classname-helper";export type PlayingCardRank =  | "A"  | "2"  | "3"  | "4"  | "5"  | "6"  | "7"  | "8"  | "9"  | "10"  | "J"  | "Q"  | "K";export type PlayingCardSuit = "clubs" | "diamonds" | "hearts" | "spades";export type PlayingCardProps = {  rank: PlayingCardRank;  suit: PlayingCardSuit;  className?: string;  faceDown?: boolean;  /** Card width in pixels. Height follows the classic 5:7 ratio. */  width?: number;};type Pip = { x: number; y: number; flip?: boolean };const suitPaths: Record<PlayingCardSuit, string> = {  clubs:    "M12 2a4.1 4.1 0 0 0-4.1 4.1c0 .93.31 1.79.83 2.48a4.1 4.1 0 1 0 2.9 7.33c-.28 2.2-1.02 3.72-2.43 5.09h5.6c-1.41-1.37-2.15-2.89-2.43-5.09a4.1 4.1 0 1 0 2.9-7.33c.52-.69.83-1.55.83-2.48A4.1 4.1 0 0 0 12 2Z",  diamonds: "M12 1.5 19.3 12 12 22.5 4.7 12Z",  hearts:    "M12 21.4C6.1 15.9 2.6 12.4 2.6 8.6a4.85 4.85 0 0 1 4.85-4.85c1.8 0 3.5.87 4.55 2.33a5.63 5.63 0 0 1 4.55-2.33A4.85 4.85 0 0 1 21.4 8.6c0 3.8-3.5 7.3-9.4 12.8Z",  spades:    "M12 1.8C6.9 7.2 3.6 10.3 3.6 13.6a4.35 4.35 0 0 0 7.1 3.36c-.3 1.9-1 3.26-2.35 4.54h7.3c-1.35-1.28-2.05-2.64-2.35-4.54a4.35 4.35 0 0 0 7.1-3.36c0-3.3-3.3-6.4-8.4-11.8Z",};const redSuits: ReadonlySet<PlayingCardSuit> = new Set(["diamonds", "hearts"]);const courtSuitCode: Record<PlayingCardSuit, string> = {  clubs: "C",  diamonds: "D",  hearts: "H",  spades: "S",};const left = 0;const center = 50;const right = 100;const pipLayouts: Partial<Record<PlayingCardRank, readonly Pip[]>> = {  "2": [    { x: center, y: 0 },    { x: center, y: 100, flip: true },  ],  "3": [    { x: center, y: 0 },    { x: center, y: 50 },    { x: center, y: 100, flip: true },  ],  "4": [    { x: left, y: 0 },    { x: right, y: 0 },    { x: left, y: 100, flip: true },    { x: right, y: 100, flip: true },  ],  "5": [    { x: left, y: 0 },    { x: right, y: 0 },    { x: center, y: 50 },    { x: left, y: 100, flip: true },    { x: right, y: 100, flip: true },  ],  "6": [    { x: left, y: 0 },    { x: right, y: 0 },    { x: left, y: 50 },    { x: right, y: 50 },    { x: left, y: 100, flip: true },    { x: right, y: 100, flip: true },  ],  "7": [    { x: left, y: 0 },    { x: right, y: 0 },    { x: center, y: 25 },    { x: left, y: 50 },    { x: right, y: 50 },    { x: left, y: 100, flip: true },    { x: right, y: 100, flip: true },  ],  "8": [    { x: left, y: 0 },    { x: right, y: 0 },    { x: center, y: 25 },    { x: left, y: 50 },    { x: right, y: 50 },    { x: center, y: 75, flip: true },    { x: left, y: 100, flip: true },    { x: right, y: 100, flip: true },  ],  "9": [    { x: left, y: 0 },    { x: right, y: 0 },    { x: left, y: 33.3 },    { x: right, y: 33.3 },    { x: center, y: 50 },    { x: left, y: 66.7, flip: true },    { x: right, y: 66.7, flip: true },    { x: left, y: 100, flip: true },    { x: right, y: 100, flip: true },  ],  "10": [    { x: left, y: 0 },    { x: right, y: 0 },    { x: center, y: 16.7 },    { x: left, y: 33.3 },    { x: right, y: 33.3 },    { x: left, y: 66.7, flip: true },    { x: right, y: 66.7, flip: true },    { x: center, y: 83.3, flip: true },    { x: left, y: 100, flip: true },    { x: right, y: 100, flip: true },  ],};const courtRanks: ReadonlySet<PlayingCardRank> = new Set(["J", "Q", "K"]);function SuitIcon({  className,  suit,}: {  className?: string;  suit: PlayingCardSuit;}) {  return (    <svg      aria-hidden="true"      className={className}      fill="currentColor"      viewBox="0 0 24 24"    >      <path d={suitPaths[suit]} />    </svg>  );}function CornerIndex({  flipped = false,  rank,  suit,}: {  flipped?: boolean;  rank: PlayingCardRank;  suit: PlayingCardSuit;}) {  return (    <div      className={cn(        "absolute flex flex-col items-center gap-[0.2em]",        flipped          ? "right-[0.65em] bottom-[0.6em] rotate-180"          : "top-[0.6em] left-[0.65em]",      )}    >      <span className="font-semibold text-[1.55em] leading-none tracking-tight">        {rank}      </span>      <SuitIcon className="size-[1.05em]" suit={suit} />    </div>  );}export function PlayingCard({  className,  faceDown = false,  rank,  suit,  width = 112,}: PlayingCardProps) {  const isRed = redSuits.has(suit);  const pips = pipLayouts[rank];  const isCourt = courtRanks.has(rank);  return (    <div      className={cn(        "relative aspect-[5/7] select-none overflow-hidden rounded-[0.9em] border border-black/10 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.05),0_4px_10px_rgba(0,0,0,0.07)]",        !faceDown && (isRed ? "text-[#c22f2f]" : "text-[#23262d]"),        className,      )}      style={{ fontSize: width / 14, width }}    >      {faceDown ? (        <div          className="absolute inset-[0.55em] rounded-[0.5em] border border-black/10"          style={{            backgroundColor: "#9d2c35",            // Crosshatch gradients sit underneath the PNG pattern as a            // fallback while the image loads.            backgroundImage:              "url('/experiments/card-back.png'), repeating-linear-gradient(45deg, rgba(255,255,255,0.16) 0, rgba(255,255,255,0.16) 1.5px, transparent 1.5px, transparent 7px), repeating-linear-gradient(-45deg, rgba(255,255,255,0.16) 0, rgba(255,255,255,0.16) 1.5px, transparent 1.5px, transparent 7px)",            backgroundPosition: "center",            backgroundSize: "cover, auto, auto",          }}        />      ) : (        <>          <CornerIndex rank={rank} suit={suit} />          <CornerIndex flipped rank={rank} suit={suit} />          {rank === "A" ? (            <div className="absolute inset-0 flex items-center justify-center">              <SuitIcon className="size-[4.6em]" suit={suit} />            </div>          ) : null}          {isCourt ? (            <div className="absolute inset-x-[24%] inset-y-[16%] overflow-hidden rounded-[0.4em] border border-current/35">              {/* Decorative: the corner indices already convey rank and suit. */}              <Image                alt=""                className="object-cover"                draggable={false}                fill                sizes={`${Math.round(width * 0.52)}px`}                src={`/experiments/courts/${rank}${courtSuitCode[suit]}.png`}              />            </div>          ) : null}          {pips ? (            <div className="absolute inset-x-[24%] inset-y-[15%]">              {pips.map((pip) => (                <span                  className="-translate-x-1/2 -translate-y-1/2 absolute"                  key={`${pip.x}-${pip.y}`}                  style={{ left: `${pip.x}%`, top: `${pip.y}%` }}                >                  <SuitIcon                    className={cn("size-[2.1em]", pip.flip && "rotate-180")}                    suit={suit}                  />                </span>              ))}            </div>          ) : null}        </>      )}    </div>  );}

Want to work with me?

I run a lil design engineering studio in London where we do fractional design engineering for startups.

THEINTERFACECOMPANYOF LONDON