Card Dealer
A card table with a face-down deck, a fanned hand and a discard pile. Cards arc across the table between them and flip in flight, hop over each other when the hand is sorted, and lift on hover. Data-driven by each card's zone, with custom faces and backs, keyboard-playable hand cards and reduced-motion support.
Preview
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/motion/button";
import { CardDealer, type CardDealerCard } from "@/components/motion/card-dealer";
const SUITS = [
{ key: "spades", symbol: "♠", red: false },
{ key: "hearts", symbol: "♥", red: true },
{ key: "clubs", symbol: "♣", red: false },
{ key: "diamonds", symbol: "♦", red: true },
] as const;
const RANKS = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"];
const RANK_NAMES: Record<string, string> = { A: "Ace", J: "Jack", Q: "Queen", K: "King" };
const HAND_LIMIT = 13;
type PlayingCard = CardDealerCard & { rank: number; suit: number };
// Seeded, so the server and the first client render agree on the deck order.
function shuffle<T>(items: T[], seed: number) {
const out = [...items];
let state = seed;
for (let i = out.length - 1; i > 0; i--) {
state = (state * 1103515245 + 12345) % 2147483648;
const j = state % (i + 1);
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}
function createDeck(): PlayingCard[] {
const cards = SUITS.flatMap((suit, s) =>
RANKS.map((rank, r) => ({
id: `${rank}-${suit.key}`,
zone: "deck" as const,
label: `${RANK_NAMES[rank] ?? rank} of ${suit.key}`,
rank: r,
suit: s,
})),
);
return shuffle(cards, 7);
}
/** Moves cards to the end of the list so they land on top of their new zone. */
function moveTo(cards: PlayingCard[], ids: string[], zone: PlayingCard["zone"]) {
const moving = new Set(ids);
const picked = ids
.map((id) => cards.find((card) => card.id === id))
.filter((card): card is PlayingCard => Boolean(card))
.map((card) => ({ ...card, zone }));
return [...cards.filter((card) => !moving.has(card.id)), ...picked];
}
function Face({ card }: { card: PlayingCard }) {
const suit = SUITS[card.suit];
const rank = RANKS[card.rank];
return (
<span
className={`flex size-full flex-col justify-between rounded-xl border border-black/10 bg-white p-2 shadow-[0_6px_16px_-6px_rgb(0_0_0/0.35)] ${suit.red ? "text-rose-500" : "text-neutral-900"}`}
>
<span className="flex flex-col items-start text-sm font-semibold leading-none">
{rank}
<span className="text-xs">{suit.symbol}</span>
</span>
<span className="self-center text-3xl leading-none">{suit.symbol}</span>
<span className="flex rotate-180 flex-col items-start text-sm font-semibold leading-none">
{rank}
<span className="text-xs">{suit.symbol}</span>
</span>
</span>
);
}
function Back() {
return (
<span className="block size-full rounded-xl border border-white/20 bg-neutral-800 p-1.5 shadow-[0_6px_16px_-6px_rgb(0_0_0/0.35)]">
<span className="block size-full rounded-lg border border-white/25 bg-[repeating-linear-gradient(45deg,rgb(255_255_255/0.12)_0_5px,transparent_5px_10px)]" />
</span>
);
}
export function CardDealerPreview() {
const [cards, setCards] = useState(createDeck);
const deck = cards.filter((card) => card.zone === "deck");
const hand = cards.filter((card) => card.zone === "hand");
// Open with a dealt hand, after the table has measured itself.
useEffect(() => {
const timer = window.setTimeout(() => {
setCards((current) => {
const top = current.filter((card) => card.zone === "deck").slice(-5).reverse();
return moveTo(current, top.map((card) => card.id), "hand");
});
}, 400);
return () => window.clearTimeout(timer);
}, []);
// Deals from the top of the deck, never past the hand limit.
const deal = (count: number) =>
setCards((current) => {
const room = HAND_LIMIT - current.filter((card) => card.zone === "hand").length;
const top = current
.filter((card) => card.zone === "deck")
.slice(-Math.max(0, Math.min(count, room)))
.reverse();
return top.length ? moveTo(current, top.map((card) => card.id), "hand") : current;
});
const sort = () =>
setCards((current) =>
moveTo(
current,
current
.filter((card) => card.zone === "hand")
.sort((a, b) => a.suit - b.suit || a.rank - b.rank)
.map((card) => card.id),
"hand",
),
);
const shuffleHand = () =>
setCards((current) =>
moveTo(
current,
shuffle(
current.filter((card) => card.zone === "hand").map((card) => card.id),
Date.now() % 2147483647,
),
"hand",
),
);
const collect = () =>
setCards((current) =>
moveTo(
current,
shuffle(
current.filter((card) => card.zone !== "deck").map((card) => card.id),
Date.now() % 2147483647,
),
"deck",
),
);
return (
<div className="flex w-full max-w-xl flex-col gap-4">
<CardDealer
cards={cards}
renderFace={(card) => <Face card={card} />}
renderBack={() => <Back />}
onCardSelect={(id) => setCards((current) => moveTo(current, [id], "pile"))}
aria-label="Card table. Select a card in your hand to play it."
/>
<div className="flex flex-wrap items-center justify-center gap-2">
<Button size="sm" onClick={() => deal(1)} disabled={!deck.length || hand.length >= HAND_LIMIT}>
Deal
</Button>
<Button size="sm" onClick={() => deal(3)} disabled={!deck.length || hand.length >= HAND_LIMIT}>
Deal 3
</Button>
<Button size="sm" variant="secondary" onClick={sort} disabled={hand.length < 2}>
Sort
</Button>
<Button size="sm" variant="secondary" onClick={shuffleHand} disabled={hand.length < 2}>
Shuffle
</Button>
<Button size="sm" variant="secondary" onClick={collect} disabled={deck.length === cards.length}>
Collect
</Button>
</div>
</div>
);
}
"use client";
// beui.dev/components/blocks/card-dealer
import {
type AnimationPlaybackControls,
arc,
motion,
type Transition,
useAnimate,
useReducedMotion,
} from "motion/react";
import {
type ReactNode,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export type CardDealerZone = "deck" | "hand" | "pile";
export interface CardDealerCard {
id: string;
zone: CardDealerZone;
/** Accessible name for a card in the hand. */
label: string;
}
export interface CardDealerProps<T extends CardDealerCard> {
/** Every card on the table. Order within each zone is the stacking and fan order. */
cards: T[];
/** The card's face. */
renderFace: (card: T) => ReactNode;
/** The card's back, shown while it sits in the deck. */
renderBack?: (card: T) => ReactNode;
/** Makes hand cards buttons. Called with the card's id. */
onCardSelect?: (id: string) => void;
/** Card size in px. Default 84 by 120. */
cardWidth?: number;
cardHeight?: number;
/** Most horizontal space between neighbouring hand cards, in px. Default 46. */
spread?: number;
/** Rotation between neighbouring hand cards, in degrees. Default 5. */
angle?: number;
/** Accessible name for the table. Default "Card table". */
"aria-label"?: string;
className?: string;
}
// A card crossing the table arcs high and lands with a small settle; a card
// changing slots inside the hand only hops. Both are tuned here rather than
// borrowed from lib/ease because the arc length is the whole effect.
const FLIGHT: Transition = { type: "spring", visualDuration: 0.55, bounce: 0.18 };
const HOP: Transition = { type: "spring", visualDuration: 0.4, bounce: 0.2 };
// arc() bends relative to the distance travelled. A negative strength puts
// horizontal moves above the line in both directions (automatic direction
// would bend them below).
const FLIGHT_STRENGTH = -0.35;
const HOP_STRENGTH = -0.3;
// Cards in a multi-card move leave one after another, not as a single slab.
// A big move shares a fixed window, so dealing the whole deck still flows.
const STAGGER = 0.045;
const MAX_STAGGER_WINDOW = 0.6;
const EDGE = 16;
const HOVER_LIFT = 18;
const HOVER_PUSH = 14;
// The fan's outermost tilt and drop, whatever the hand size. Small hands sit
// well inside both; a whole deck spread into the hand becomes one shallow
// ribbon instead of cards tipping past vertical.
const MAX_FAN_ANGLE = 40;
const MAX_FAN_DROP = 26;
const FAN_DROP = 2.2;
type Pose = { x: number; y: number; rotate: number; faceDown: boolean; z: number };
/** Stable per-card tilt for the pile, so a card keeps its angle once it lands. */
function pileTilt(id: string) {
let hash = 0;
for (const char of id) hash = (hash * 31 + char.charCodeAt(0)) | 0;
return ((Math.abs(hash) % 17) - 8) * 1.1;
}
/**
* A card table: a face-down deck, a fanned hand and a discard pile. Every card
* stays mounted and moving one between zones is just a change of `zone`, so it
* arcs across the table to its new place, flipping face up or down in flight.
* Reordering the hand makes cards hop over each other into their new slots.
*/
export function CardDealer<T extends CardDealerCard>({
cards,
renderFace,
renderBack,
onCardSelect,
cardWidth = 84,
cardHeight = 120,
spread = 46,
angle = 5,
"aria-label": ariaLabel = "Card table",
className,
}: CardDealerProps<T>) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const stageRef = useRef<HTMLElement>(null);
const [size, setSize] = useState<{ width: number; height: number } | null>(null);
const [hovered, setHovered] = useState<string | null>(null);
// Where each card was last rendered, read during render to tell a flight
// from a hop from a card that stayed put. Written after every commit.
const previous = useRef(new Map<string, { zone: CardDealerZone; slot: number }>());
const resized = useRef(false);
useLayoutEffect(() => {
const stage = stageRef.current;
if (!stage) return;
const measure = () => {
resized.current = true;
setSize({ width: stage.clientWidth, height: stage.clientHeight });
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(stage);
return () => observer.disconnect();
}, []);
const deck = cards.filter((card) => card.zone === "deck");
const hand = cards.filter((card) => card.zone === "hand");
const pile = cards.filter((card) => card.zone === "pile");
useLayoutEffect(() => {
const next = new Map<string, { zone: CardDealerZone; slot: number }>();
for (const list of [deck, hand, pile])
list.forEach((card, slot) => {
next.set(card.id, { zone: card.zone, slot });
});
previous.current = next;
resized.current = false;
});
const poseFor = (card: T, slot: number): Pose => {
const width = size?.width ?? 0;
const height = size?.height ?? 0;
if (card.zone === "deck") {
// A thin visible edge for the first few cards, then the stack stops growing.
const depth = Math.min(slot, 6) * 0.6;
return { x: EDGE - depth, y: EDGE - depth, rotate: 0, faceDown: true, z: slot };
}
if (card.zone === "pile") {
return {
x: width - cardWidth - EDGE,
y: EDGE,
rotate: pileTilt(card.id),
faceDown: false,
z: 400 + slot,
};
}
const count = hand.length;
const offset = slot - (count - 1) / 2;
const edge = Math.max((count - 1) / 2, 1);
const reach = offset / edge;
const tilt = Math.min(angle * edge, MAX_FAN_ANGLE);
const drop = Math.min(FAN_DROP * edge * edge, MAX_FAN_DROP);
const room = width - cardWidth - EDGE * 2;
const step = count > 1 ? Math.min(spread, room / (count - 1)) : 0;
const hoveredSlot = hovered ? hand.findIndex((item) => item.id === hovered) : -1;
const push =
hoveredSlot < 0 || slot === hoveredSlot
? 0
: Math.sign(slot - hoveredSlot) * HOVER_PUSH;
return {
x: width / 2 - cardWidth / 2 + offset * step + push,
// Outer cards sit lower so the fan reads as held, not laid flat.
y:
height -
cardHeight -
EDGE +
reach * reach * drop -
(slot === hoveredSlot ? HOVER_LIFT : 0),
rotate: reach * tilt,
faceDown: false,
z: slot === hoveredSlot ? 300 : 100 + slot,
};
};
// Moving cards leave in stacking order, so a deal or a collect fans out.
let moving = 0;
const movers = cards.filter((card) => {
const was = previous.current.get(card.id);
return was !== undefined && was.zone !== card.zone;
}).length;
const stagger = movers > 1 ? Math.min(STAGGER, MAX_STAGGER_WINDOW / (movers - 1)) : 0;
const transitionFor = (card: T, slot: number): Transition => {
if (reduce || resized.current) return { duration: 0 };
const was = previous.current.get(card.id);
if (!was) return { duration: 0 };
if (was.zone !== card.zone) {
const delay = moving * stagger;
moving += 1;
return { ...FLIGHT, delay, path: arc({ strength: FLIGHT_STRENGTH }) };
}
if (card.zone === "hand" && was.slot !== slot) {
return { ...HOP, path: arc({ strength: HOP_STRENGTH }) };
}
return HOP;
};
const renderCard = (card: T, slot: number) => {
const pose = poseFor(card, slot);
const interactive = card.zone === "hand" && onCardSelect !== undefined;
const faces = (
<motion.span
aria-hidden="true"
className="relative block size-full [transform-style:preserve-3d]"
initial={false}
animate={{ rotateY: pose.faceDown ? 180 : 0 }}
transition={reduce ? { duration: 0 } : FLIGHT}
>
<span className="absolute inset-0 overflow-hidden rounded-xl [backface-visibility:hidden]">
{renderFace(card)}
</span>
<span className="absolute inset-0 overflow-hidden rounded-xl [backface-visibility:hidden] [transform:rotateY(180deg)]">
{renderBack ? renderBack(card) : <DefaultBack />}
</span>
</motion.span>
);
return (
<CardFrame
key={card.id}
pose={pose}
transition={transitionFor(card, slot)}
width={cardWidth}
height={cardHeight}
>
{/* The faces never change element, so a card flips in flight instead
of remounting face up; the button lies over them only in the hand. */}
<div className="size-full [perspective:800px]">{faces}</div>
{interactive ? (
<button
type="button"
aria-label={card.label}
onClick={() => onCardSelect?.(card.id)}
onPointerEnter={(event) => {
if (canHover && event.pointerType === "mouse") setHovered(card.id);
}}
onPointerLeave={() => setHovered((id) => (id === card.id ? null : id))}
onFocus={() => setHovered(card.id)}
onBlur={() => setHovered((id) => (id === card.id ? null : id))}
className="absolute inset-0 cursor-pointer rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
) : null}
</CardFrame>
);
};
return (
<section
ref={stageRef}
aria-label={ariaLabel}
className={cn("relative h-80 w-full touch-manipulation select-none", className)}
>
{/* One flat list, so a card changing zones keeps its element and flies
instead of remounting; zone by zone with the hand in fan order, so
Tab follows the hand. */}
{size
? [deck, hand, pile].flatMap((zone) =>
zone.map((card, slot) => renderCard(card, slot)),
)
: null}
</section>
);
}
/**
* Moves one card. Imperative so each new move can stop the last one first:
* a declarative arc path keeps running when its target changes, and a long
* flight finishing after a quick hop would write its stale end point back.
*/
function CardFrame({
pose,
transition,
width,
height,
children,
}: {
pose: Pose;
transition: Transition;
width: number;
height: number;
children: ReactNode;
}) {
const [frame, animateFrame] = useAnimate<HTMLDivElement>();
const running = useRef<AnimationPlaybackControls | null>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: a move starts when the pose changes; the transition belongs to that same render and must not restart a move on its own.
useLayoutEffect(() => {
if (!frame.current) return;
running.current?.stop();
running.current = animateFrame(
frame.current,
{ x: pose.x, y: pose.y, rotate: pose.rotate },
transition,
);
}, [pose.x, pose.y, pose.rotate]);
useLayoutEffect(() => () => running.current?.stop(), []);
return (
<div
ref={frame}
className="absolute top-0 left-0"
style={{ width, height, zIndex: pose.z }}
>
{children}
</div>
);
}
function DefaultBack() {
return (
<span className="block size-full rounded-xl border border-border bg-[repeating-linear-gradient(45deg,var(--muted)_0_6px,var(--background)_6px_12px)]" />
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
Copy the source code
"use client";
// beui.dev/components/blocks/card-dealer
import {
type AnimationPlaybackControls,
arc,
motion,
type Transition,
useAnimate,
useReducedMotion,
} from "motion/react";
import {
type ReactNode,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export type CardDealerZone = "deck" | "hand" | "pile";
export interface CardDealerCard {
id: string;
zone: CardDealerZone;
/** Accessible name for a card in the hand. */
label: string;
}
export interface CardDealerProps<T extends CardDealerCard> {
/** Every card on the table. Order within each zone is the stacking and fan order. */
cards: T[];
/** The card's face. */
renderFace: (card: T) => ReactNode;
/** The card's back, shown while it sits in the deck. */
renderBack?: (card: T) => ReactNode;
/** Makes hand cards buttons. Called with the card's id. */
onCardSelect?: (id: string) => void;
/** Card size in px. Default 84 by 120. */
cardWidth?: number;
cardHeight?: number;
/** Most horizontal space between neighbouring hand cards, in px. Default 46. */
spread?: number;
/** Rotation between neighbouring hand cards, in degrees. Default 5. */
angle?: number;
/** Accessible name for the table. Default "Card table". */
"aria-label"?: string;
className?: string;
}
// A card crossing the table arcs high and lands with a small settle; a card
// changing slots inside the hand only hops. Both are tuned here rather than
// borrowed from lib/ease because the arc length is the whole effect.
const FLIGHT: Transition = { type: "spring", visualDuration: 0.55, bounce: 0.18 };
const HOP: Transition = { type: "spring", visualDuration: 0.4, bounce: 0.2 };
// arc() bends relative to the distance travelled. A negative strength puts
// horizontal moves above the line in both directions (automatic direction
// would bend them below).
const FLIGHT_STRENGTH = -0.35;
const HOP_STRENGTH = -0.3;
// Cards in a multi-card move leave one after another, not as a single slab.
// A big move shares a fixed window, so dealing the whole deck still flows.
const STAGGER = 0.045;
const MAX_STAGGER_WINDOW = 0.6;
const EDGE = 16;
const HOVER_LIFT = 18;
const HOVER_PUSH = 14;
// The fan's outermost tilt and drop, whatever the hand size. Small hands sit
// well inside both; a whole deck spread into the hand becomes one shallow
// ribbon instead of cards tipping past vertical.
const MAX_FAN_ANGLE = 40;
const MAX_FAN_DROP = 26;
const FAN_DROP = 2.2;
type Pose = { x: number; y: number; rotate: number; faceDown: boolean; z: number };
/** Stable per-card tilt for the pile, so a card keeps its angle once it lands. */
function pileTilt(id: string) {
let hash = 0;
for (const char of id) hash = (hash * 31 + char.charCodeAt(0)) | 0;
return ((Math.abs(hash) % 17) - 8) * 1.1;
}
/**
* A card table: a face-down deck, a fanned hand and a discard pile. Every card
* stays mounted and moving one between zones is just a change of `zone`, so it
* arcs across the table to its new place, flipping face up or down in flight.
* Reordering the hand makes cards hop over each other into their new slots.
*/
export function CardDealer<T extends CardDealerCard>({
cards,
renderFace,
renderBack,
onCardSelect,
cardWidth = 84,
cardHeight = 120,
spread = 46,
angle = 5,
"aria-label": ariaLabel = "Card table",
className,
}: CardDealerProps<T>) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const stageRef = useRef<HTMLElement>(null);
const [size, setSize] = useState<{ width: number; height: number } | null>(null);
const [hovered, setHovered] = useState<string | null>(null);
// Where each card was last rendered, read during render to tell a flight
// from a hop from a card that stayed put. Written after every commit.
const previous = useRef(new Map<string, { zone: CardDealerZone; slot: number }>());
const resized = useRef(false);
useLayoutEffect(() => {
const stage = stageRef.current;
if (!stage) return;
const measure = () => {
resized.current = true;
setSize({ width: stage.clientWidth, height: stage.clientHeight });
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(stage);
return () => observer.disconnect();
}, []);
const deck = cards.filter((card) => card.zone === "deck");
const hand = cards.filter((card) => card.zone === "hand");
const pile = cards.filter((card) => card.zone === "pile");
useLayoutEffect(() => {
const next = new Map<string, { zone: CardDealerZone; slot: number }>();
for (const list of [deck, hand, pile])
list.forEach((card, slot) => {
next.set(card.id, { zone: card.zone, slot });
});
previous.current = next;
resized.current = false;
});
const poseFor = (card: T, slot: number): Pose => {
const width = size?.width ?? 0;
const height = size?.height ?? 0;
if (card.zone === "deck") {
// A thin visible edge for the first few cards, then the stack stops growing.
const depth = Math.min(slot, 6) * 0.6;
return { x: EDGE - depth, y: EDGE - depth, rotate: 0, faceDown: true, z: slot };
}
if (card.zone === "pile") {
return {
x: width - cardWidth - EDGE,
y: EDGE,
rotate: pileTilt(card.id),
faceDown: false,
z: 400 + slot,
};
}
const count = hand.length;
const offset = slot - (count - 1) / 2;
const edge = Math.max((count - 1) / 2, 1);
const reach = offset / edge;
const tilt = Math.min(angle * edge, MAX_FAN_ANGLE);
const drop = Math.min(FAN_DROP * edge * edge, MAX_FAN_DROP);
const room = width - cardWidth - EDGE * 2;
const step = count > 1 ? Math.min(spread, room / (count - 1)) : 0;
const hoveredSlot = hovered ? hand.findIndex((item) => item.id === hovered) : -1;
const push =
hoveredSlot < 0 || slot === hoveredSlot
? 0
: Math.sign(slot - hoveredSlot) * HOVER_PUSH;
return {
x: width / 2 - cardWidth / 2 + offset * step + push,
// Outer cards sit lower so the fan reads as held, not laid flat.
y:
height -
cardHeight -
EDGE +
reach * reach * drop -
(slot === hoveredSlot ? HOVER_LIFT : 0),
rotate: reach * tilt,
faceDown: false,
z: slot === hoveredSlot ? 300 : 100 + slot,
};
};
// Moving cards leave in stacking order, so a deal or a collect fans out.
let moving = 0;
const movers = cards.filter((card) => {
const was = previous.current.get(card.id);
return was !== undefined && was.zone !== card.zone;
}).length;
const stagger = movers > 1 ? Math.min(STAGGER, MAX_STAGGER_WINDOW / (movers - 1)) : 0;
const transitionFor = (card: T, slot: number): Transition => {
if (reduce || resized.current) return { duration: 0 };
const was = previous.current.get(card.id);
if (!was) return { duration: 0 };
if (was.zone !== card.zone) {
const delay = moving * stagger;
moving += 1;
return { ...FLIGHT, delay, path: arc({ strength: FLIGHT_STRENGTH }) };
}
if (card.zone === "hand" && was.slot !== slot) {
return { ...HOP, path: arc({ strength: HOP_STRENGTH }) };
}
return HOP;
};
const renderCard = (card: T, slot: number) => {
const pose = poseFor(card, slot);
const interactive = card.zone === "hand" && onCardSelect !== undefined;
const faces = (
<motion.span
aria-hidden="true"
className="relative block size-full [transform-style:preserve-3d]"
initial={false}
animate={{ rotateY: pose.faceDown ? 180 : 0 }}
transition={reduce ? { duration: 0 } : FLIGHT}
>
<span className="absolute inset-0 overflow-hidden rounded-xl [backface-visibility:hidden]">
{renderFace(card)}
</span>
<span className="absolute inset-0 overflow-hidden rounded-xl [backface-visibility:hidden] [transform:rotateY(180deg)]">
{renderBack ? renderBack(card) : <DefaultBack />}
</span>
</motion.span>
);
return (
<CardFrame
key={card.id}
pose={pose}
transition={transitionFor(card, slot)}
width={cardWidth}
height={cardHeight}
>
{/* The faces never change element, so a card flips in flight instead
of remounting face up; the button lies over them only in the hand. */}
<div className="size-full [perspective:800px]">{faces}</div>
{interactive ? (
<button
type="button"
aria-label={card.label}
onClick={() => onCardSelect?.(card.id)}
onPointerEnter={(event) => {
if (canHover && event.pointerType === "mouse") setHovered(card.id);
}}
onPointerLeave={() => setHovered((id) => (id === card.id ? null : id))}
onFocus={() => setHovered(card.id)}
onBlur={() => setHovered((id) => (id === card.id ? null : id))}
className="absolute inset-0 cursor-pointer rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
) : null}
</CardFrame>
);
};
return (
<section
ref={stageRef}
aria-label={ariaLabel}
className={cn("relative h-80 w-full touch-manipulation select-none", className)}
>
{/* One flat list, so a card changing zones keeps its element and flies
instead of remounting; zone by zone with the hand in fan order, so
Tab follows the hand. */}
{size
? [deck, hand, pile].flatMap((zone) =>
zone.map((card, slot) => renderCard(card, slot)),
)
: null}
</section>
);
}
/**
* Moves one card. Imperative so each new move can stop the last one first:
* a declarative arc path keeps running when its target changes, and a long
* flight finishing after a quick hop would write its stale end point back.
*/
function CardFrame({
pose,
transition,
width,
height,
children,
}: {
pose: Pose;
transition: Transition;
width: number;
height: number;
children: ReactNode;
}) {
const [frame, animateFrame] = useAnimate<HTMLDivElement>();
const running = useRef<AnimationPlaybackControls | null>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: a move starts when the pose changes; the transition belongs to that same render and must not restart a move on its own.
useLayoutEffect(() => {
if (!frame.current) return;
running.current?.stop();
running.current = animateFrame(
frame.current,
{ x: pose.x, y: pose.y, rotate: pose.rotate },
transition,
);
}, [pose.x, pose.y, pose.rotate]);
useLayoutEffect(() => () => running.current?.stop(), []);
return (
<div
ref={frame}
className="absolute top-0 left-0"
style={{ width, height, zIndex: pose.z }}
>
{children}
</div>
);
}
function DefaultBack() {
return (
<span className="block size-full rounded-xl border border-border bg-[repeating-linear-gradient(45deg,var(--muted)_0_6px,var(--background)_6px_12px)]" />
);
}
export type {
ButtonLinkProps,
ButtonProps,
ButtonSize,
ButtonVariant,
} from "./base";
export { Button, ButtonLink } from "./base";
export type { MagneticButtonProps } from "./magnetic";
export { MagneticButton } from "./magnetic";
export type { MetallicButtonProps } from "./metallic";
export { MetallicButton } from "./metallic";
export type { ButtonState, StatefulButtonProps } from "./stateful";
export { StatefulButton } from "./stateful";
"use client";
import {
AnimatePresence,
type HTMLMotionProps,
motion,
useReducedMotion,
} from "motion/react";
import {
forwardRef,
type PointerEvent,
type ReactNode,
useCallback,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "outline";
export type ButtonSize = "sm" | "md" | "lg" | "icon";
export interface ButtonProps extends Omit<
HTMLMotionProps<"button">,
"children"
> {
variant?: ButtonVariant;
size?: ButtonSize;
pressScale?: number;
/** Spawn a Material-style ripple from the press point. Off by default. */
ripple?: boolean;
children?: ReactNode;
}
export interface ButtonLinkProps extends Omit<
HTMLMotionProps<"a">,
"children"
> {
variant?: ButtonVariant;
size?: ButtonSize;
pressScale?: number;
children?: ReactNode;
}
type Ripple = { id: number; x: number; y: number; size: number };
const VARIANT_CLASS: Record<ButtonVariant, string> = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "border border-border bg-card text-foreground hover:border-border",
ghost: "text-muted-foreground hover:text-foreground hover:bg-muted/60",
outline:
"border border-border bg-transparent text-foreground hover:bg-muted/60",
};
const SIZE_CLASS: Record<ButtonSize, string> = {
sm: "h-8 px-3 text-xs gap-1.5 rounded-full",
md: "h-10 px-5 text-sm gap-2 rounded-full",
lg: "h-12 px-6 text-base gap-2 rounded-full",
icon: "h-8 w-8 rounded-lg",
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
function Button(
{
variant = "primary",
size = "md",
pressScale = 0.93,
ripple = false,
className,
children,
onPointerDown,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const [ripples, setRipples] = useState<Ripple[]>([]);
const nextId = useRef(0);
const handlePointerDown = useCallback(
(event: PointerEvent<HTMLButtonElement>) => {
if (ripple && !reduce) {
const rect = event.currentTarget.getBoundingClientRect();
const size = Math.max(rect.width, rect.height) * 2;
const id = nextId.current++;
setRipples((prev) => [
...prev,
{
id,
x: event.clientX - rect.left,
y: event.clientY - rect.top,
size,
},
]);
}
onPointerDown?.(event);
},
[ripple, reduce, onPointerDown],
);
return (
<motion.button
ref={ref}
type="button"
whileTap={reduce ? undefined : { scale: pressScale }}
whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}
transition={SPRING_PRESS}
onPointerDown={handlePointerDown}
className={cn(
"inline-flex items-center justify-center font-medium select-none",
"transition-colors",
"disabled:pointer-events-none disabled:opacity-50",
ripple && "relative overflow-hidden",
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
{...rest}
>
{ripple && !reduce ? (
<span className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]">
<AnimatePresence>
{ripples.map((r) => (
<motion.span
key={r.id}
className="absolute rounded-full bg-current"
style={{
left: r.x,
top: r.y,
width: r.size,
height: r.size,
x: "-50%",
y: "-50%",
}}
initial={{ scale: 0.05, opacity: 0.3 }}
animate={{ scale: 1, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 1.6, ease: EASE_OUT }}
onAnimationComplete={() =>
setRipples((prev) => prev.filter((x) => x.id !== r.id))
}
/>
))}
</AnimatePresence>
</span>
) : null}
{children}
</motion.button>
);
},
);
export const ButtonLink = forwardRef<HTMLAnchorElement, ButtonLinkProps>(
function ButtonLink(
{
variant = "primary",
size = "md",
pressScale = 0.93,
className,
children,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
return (
<motion.a
ref={ref}
whileTap={reduce ? undefined : { scale: pressScale }}
whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}
transition={SPRING_PRESS}
className={cn(
"inline-flex items-center justify-center font-medium select-none",
"transition-colors",
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
{...rest}
>
{children}
</motion.a>
);
},
);
"use client";
import { forwardRef } from "react";
import { Magnetic } from "../magnetic";
import { Button, type ButtonProps } from "./base";
export interface MagneticButtonProps extends ButtonProps {
/** Magnetic pull strength. Default 0.25. */
strength?: number;
/** Class applied to the magnetic wrapper. */
magneticClassName?: string;
}
export const MagneticButton = forwardRef<HTMLButtonElement, MagneticButtonProps>(function MagneticButton(
{ strength = 0.25, magneticClassName, children, ...rest },
ref,
) {
return (
<Magnetic strength={strength} className={magneticClassName}>
<Button ref={ref} {...rest}>
{children}
</Button>
</Magnetic>
);
});
"use client";
import { motion, useReducedMotion } from "motion/react";
import { forwardRef, useState } from "react";
import { EASE_IN_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { Button, type ButtonProps } from "./base";
export interface MetallicButtonProps extends Omit<
ButtonProps,
"ripple" | "variant"
> {
/** Stops the traveling reflection while preserving the chrome rim. */
paused?: boolean;
}
// The rim and highlight drift separately so the material stays quiet and reflective.
const SILVER_DRIFT = {
duration: 8,
ease: EASE_IN_OUT,
repeat: Infinity,
};
const CHROME_SHIMMER = {
duration: 2.4,
ease: EASE_IN_OUT,
};
export const MetallicButton = forwardRef<
HTMLButtonElement,
MetallicButtonProps
>(function MetallicButton(
{
size = "md",
paused = false,
className,
children,
onHoverStart,
onHoverEnd,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const still = paused || Boolean(reduce);
const [hovered, setHovered] = useState(false);
return (
<Button
ref={ref}
variant="ghost"
size={size}
onHoverStart={(event, info) => {
setHovered(true);
onHoverStart?.(event, info);
}}
onHoverEnd={(event, info) => {
setHovered(false);
onHoverEnd?.(event, info);
}}
className={cn(
"group relative isolate overflow-hidden border-0 bg-transparent text-foreground",
"hover:bg-transparent hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"shadow-[0_8px_22px_rgba(0,0,0,0.16)]",
size === "icon" && "rounded-full",
className,
)}
{...rest}
>
<motion.span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-[-18%] z-0 w-[136%] rounded-[inherit] bg-[linear-gradient(105deg,#111_0%,#737373_14%,#fafafa_26%,#525252_38%,#0a0a0a_50%,#a3a3a3_64%,#fff_75%,#404040_87%,#111_100%)]"
animate={still ? undefined : { x: ["0%", "13%", "0%"] }}
transition={still ? undefined : SILVER_DRIFT}
/>
<motion.span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-[-58%] z-[1] w-[52%] -skew-x-12 bg-[linear-gradient(90deg,transparent,rgba(255,255,255,0.5)_48%,transparent)] opacity-50 blur-[3px] mix-blend-screen"
animate={still ? undefined : { x: hovered ? "310%" : "0%" }}
transition={still ? undefined : CHROME_SHIMMER}
/>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-[2px] z-[2] rounded-[inherit] bg-background transition-colors group-hover:bg-muted/40"
/>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-[2px] z-[3] rounded-[inherit] shadow-[inset_0_1px_0_rgba(255,255,255,0.28),inset_0_-1px_0_rgba(0,0,0,0.16)]"
/>
<span className="relative z-10 inline-flex items-center justify-center gap-2">
{children}
</span>
</Button>
);
});
"use client";
import { Check, Loader2, X } from "lucide-react";
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import {
forwardRef,
type ReactNode,
useLayoutEffect,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_SWAP } from "@/lib/ease";
import { Button, type ButtonProps } from "./base";
export type ButtonState = "idle" | "loading" | "success" | "error";
export interface StatefulButtonProps extends Omit<ButtonProps, "children"> {
state?: ButtonState;
children: ReactNode;
loadingText?: ReactNode;
successText?: ReactNode;
errorText?: ReactNode;
icon?: ReactNode;
}
const CASCADE_STAGGER = 0.025;
const ROLL_BLUR = "blur(6px)";
const CASCADE_LETTER_VARIANTS: Variants = {
initial: { opacity: 0, y: "105%", filter: ROLL_BLUR },
animate: (delay: number = 0) => ({
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: { ...SPRING_SWAP, delay },
}),
exit: (delay: number = 0) => ({
opacity: 0,
y: "-105%",
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },
}),
};
const ICON_VARIANTS: Variants = {
// Width collapses too, so the icon adds/removes its own space smoothly
// instead of popping the row width in a single frame.
initial: { opacity: 0, width: 0, scale: 0.7, filter: ROLL_BLUR },
animate: {
opacity: 1,
width: "1.5rem",
scale: 1,
filter: "blur(0px)",
transition: SPRING_SWAP,
},
exit: {
opacity: 0,
width: 0,
scale: 0.7,
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT },
},
};
function IconSlot({ keyId, children }: { keyId: string; children: ReactNode }) {
const reduce = useReducedMotion();
return (
<motion.span
key={keyId}
variants={ICON_VARIANTS}
initial={reduce ? { opacity: 0 } : "initial"}
animate={reduce ? { opacity: 1 } : "animate"}
exit={reduce ? { opacity: 0 } : "exit"}
transition={reduce ? { duration: 0.15 } : undefined}
className="inline-grid shrink-0 place-items-center overflow-hidden"
>
{children}
</motion.span>
);
}
function TextSlot({
value,
children,
}: {
value: string;
children: ReactNode;
}) {
const reduce = useReducedMotion();
const measureRef = useRef<HTMLSpanElement>(null);
const [width, setWidth] = useState<number>();
const label = typeof children === "string" ? children : null;
const cascade = label !== null && !reduce;
// Measure strings with the same per-letter layout as the cascade. Measuring
// the whole string preserves kerning, which can make it narrower than the
// inline-block letters and clip the final glyph during the width animation.
useLayoutEffect(() => {
const nextWidth = measureRef.current?.offsetWidth;
if (!nextWidth) return;
setWidth((current) => (current === nextWidth ? current : nextWidth));
});
return (
<motion.span
initial={false}
animate={{ width }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="relative inline-block overflow-hidden whitespace-nowrap align-bottom"
>
<span
ref={measureRef}
aria-hidden
className="invisible inline-block whitespace-nowrap"
>
{cascade
? label.split("").map((char, index) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
className="inline-block whitespace-pre"
>
{char}
</span>
))
: children}
</span>
{cascade ? (
<>
<span className="sr-only">{label}</span>
<AnimatePresence initial={false}>
<motion.span
key={`cascade-${value}`}
aria-hidden
initial="initial"
animate="animate"
exit="exit"
className="absolute left-0 top-0 inline-block whitespace-pre"
>
{label.split("").map((char, index) => (
<motion.span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
custom={index * CASCADE_STAGGER}
variants={CASCADE_LETTER_VARIANTS}
// Own states: a label that swaps away and straight back
// re-enters mid-exit, and only owned targets are restored.
initial="initial"
animate="animate"
exit="exit"
className="inline-block whitespace-pre will-change-[opacity,filter,transform]"
>
{char}
</motion.span>
))}
</motion.span>
</AnimatePresence>
</>
) : (
<AnimatePresence initial={false}>
<motion.span
key={`text-${value}`}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 14, filter: ROLL_BLUR }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -14, filter: ROLL_BLUR }}
transition={reduce ? { duration: 0.15 } : SPRING_SWAP}
className="absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
)}
</motion.span>
);
}
export const StatefulButton = forwardRef<HTMLButtonElement, StatefulButtonProps>(function StatefulButton(
{
state = "idle",
children,
loadingText = "Loading",
successText = "Done",
errorText = "Try again",
icon,
disabled,
...rest
},
ref,
) {
const isBusy = state === "loading";
const stateText =
state === "loading"
? loadingText
: state === "success"
? successText
: state === "error"
? errorText
: children;
const textKey =
typeof stateText === "string" ? `${state}-${stateText}` : state;
return (
<Button ref={ref} disabled={disabled || isBusy} aria-busy={isBusy} whileHover={undefined} {...rest}>
<span
aria-live="polite"
className="relative inline-flex items-center justify-center overflow-hidden"
>
<AnimatePresence initial={false}>
{state === "loading" ? (
<IconSlot keyId="loading-icon">
<Loader2 className="h-4 w-4 animate-spin" />
</IconSlot>
) : null}
{state === "success" ? (
<IconSlot keyId="success-icon">
<Check className="h-4 w-4" />
</IconSlot>
) : null}
{state === "error" ? (
<IconSlot keyId="error-icon">
<X className="h-4 w-4" />
</IconSlot>
) : null}
</AnimatePresence>
<TextSlot value={textKey}>{stateText}</TextSlot>
<AnimatePresence initial={false}>
{state === "idle" && icon ? (
<IconSlot keyId="idle-icon">{icon}</IconSlot>
) : null}
</AnimatePresence>
</span>
</Button>
);
});
"use client";
import { motion, useMotionValue, useReducedMotion, useSpring } from "motion/react";
import { useRef, type ReactNode } from "react";
import { SPRING_MOUSE } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export interface MagneticProps {
children: ReactNode;
strength?: number;
className?: string;
}
export function Magnetic({ children, strength = 0.35, className }: MagneticProps) {
const ref = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
const canHover = useHoverCapable();
// Decorative cursor-follow: skip on touch (phantom hover) and reduced motion.
const enabled = !reduce && canHover;
const x = useMotionValue(0);
const y = useMotionValue(0);
const sx = useSpring(x, SPRING_MOUSE);
const sy = useSpring(y, SPRING_MOUSE);
const onMove = (e: React.MouseEvent<HTMLDivElement>) => {
const el = ref.current;
if (!el || !enabled) return;
const rect = el.getBoundingClientRect();
x.set((e.clientX - rect.left - rect.width / 2) * strength);
y.set((e.clientY - rect.top - rect.height / 2) * strength);
};
const onLeave = () => {
x.set(0);
y.set(0);
};
return (
<motion.div
ref={ref}
onMouseMove={onMove}
onMouseLeave={onLeave}
style={{ x: sx, y: sy }}
className={cn("inline-block", className)}
>
{children}
</motion.div>
);
}
API Reference
cardsT[]Every card on the table. Order within each zone is the stacking and fan order.
—renderFace(card: T) => ReactNodeThe card's face.
—renderBack?((card: T) => ReactNode)The card's back, shown while it sits in the deck.
—onCardSelect?((id: string) => void)Makes hand cards buttons. Called with the card's id.
—cardWidth?numberCard size in px. Default 84 by 120.
84cardHeight?number120spread?numberMost horizontal space between neighbouring hand cards, in px. Default 46.
46angle?numberRotation between neighbouring hand cards, in degrees. Default 5.
5aria-label?stringAccessible name for the table. Default "Card table".
Card tableclassName?string—Related components
Project Folder
An interactive project folder that opens its file fan on hover or focus, expands into a focus-managed overlay, then retraces the complete path when closed.
Availability Scheduler
Weekly availability editor where each day springs between available and unavailable, time ranges add and remove with blur-slide motion, times pick from a scrollable dropdown, and a copy menu clones hours to other days.
Infinite Masonry
Responsive virtualized masonry that measures variable-height cards and loads more data as the user nears the end.
Card Dealer is a React card table built with Framer Motion. It lays out a face-down deck, a fanned hand and a tilted discard pile, and you move a card by changing its zone. Every card stays mounted, so the change becomes a flight: the card arcs across the table on motion's arc() path and flips face up or down in 3D on the way. Several cards moving at once leave one after another, and reordering the hand makes cards hop over each other into their new slots. Faces and backs are yours to render.
When to use it
- A card game, flashcard app or quiz needs to deal, play and collect cards with motion people expect from a real table.
- An onboarding flow or pricing page wants to deal out steps or plans as cards that can be picked and played.
- A landing page or demo wants a playful, tactile showcase of arc and flip motion.
Accessibility
The table is a labelled region. With onCardSelect, each hand card is a button named by its label, and the hand renders in fan order so Tab moves through it left to right. Focusing a card lifts it and parts its neighbours the same way hovering does. Deck and pile cards are decorative. With reduced motion enabled, cards move and flip instantly with no arc, stagger or lift animation.
FAQ
- How do I deal a card?
- Change the card's zone from deck to hand in your state. The component animates the move; you never call an animation yourself. Moving a card to the end of your array puts it on top of its new zone.
- Can I use my own card design?
- Yes. renderFace draws the front of every card and renderBack the back shown in the deck. Pass cardWidth and cardHeight to change the card size.
- Does it need playing cards?
- No. A card is any object with an id, a zone and a label, so the same table works for flashcards, plans, steps or photos.
Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
Updated