Fixtures
Animated tournament fixtures in two styles: a knockout bracket that pages through rounds, and a wheel that wraps the same tree around the champion. Both read the same array of rounds, so one dataset draws either.
Knockout Wheel
knockout-wheel.tsxThe tournament drawn radially. The champion holds the hub, each round is a ring further out, and the teams themselves form the rim. Nodes spring in ring by ring, and hovering one isolates that team while the rest recede. Teams show a flag, a logo or their initials, and a deeper draw grows another ring.
- Spain v Argentina · 2–1
- Spain v Brazil · 2–1
- France v Argentina · 0–0 (3–4 pens)
- Spain v Portugal · 1–0
- England v Brazil · 2–3
- France v Italy · 2–1
- Argentina v Germany · 3–1
- Spain v Japan · 2–0
- Netherlands v Portugal · 1–3
- England v Uruguay · 2–1
- Croatia v Brazil · 0–1
- France v Morocco · 3–1
- Belgium v Italy · 1–2
- Argentina v Mexico · 2–0
- Germany v Norway · 1–1 (4–2 pens)
- Spain v Costa Rica · 3–0
- Japan v Serbia · 2–1
- Netherlands v Ecuador · 2–0
- Ghana v Portugal · 2–3
- England v Wales · 4–0
- Canada v Uruguay · 0–2
- Croatia v Denmark · 1–0
- Brazil v Cameroon · 3–1
- France v Poland · 2–1
- Senegal v Morocco · 0–1
- Belgium v Tunisia · 2–0
- Switzerland v Italy · 1–3
- Argentina v Peru · 2–0
- Qatar v Mexico · 0–1
- Germany v Sweden · 4–2
- Austria v Norway · 1–2
"use client";
import { KnockoutWheel, ROUNDS } from "@/components/motion/knockout-wheel";
// `ROUNDS` is the sample 32-team cup that ships with the component, and it's the
// same array the knockout bracket takes, so one dataset feeds both fixture
// styles. Any other single-elimination tournament renders the same way. Build
// your own `Round[]`, widest round first, each round holding half the matches of
// the one before it, and pass it in:
//
// const rounds: Round[] = [
// {
// name: "Quarter-finals",
// matches: [
// {
// id: "qf-1",
// home: { team: { name: "Cloud9", logo: "/logos/c9.svg" }, score: 2 },
// away: { team: { name: "T1", logo: "/logos/t1.svg" }, score: 1 },
// winner: "home",
// },
// // qf-2, qf-3, qf-4 …
// ],
// },
// { name: "Semi-finals", matches: [/* fed by qf 1+2 and qf 3+4 */] },
// { name: "Grand final", matches: [/* the one final */] },
// ];
//
// The wheel grows a ring per round and holds a 32rem stage at every size, so it
// pans on a phone rather than shrinking its marks. A team carries a `logo` URL,
// an ISO country `code` for a flag, or neither, in which case its initials stand
// in. `initialRound` drops the outer rounds.
export function KnockoutWheelPreview() {
return (
<div className="w-full py-8">
<KnockoutWheel rounds={ROUNDS} />
</div>
);
}
"use client";
// beui.dev/components/blocks/knockout-bracket
import { Shield } from "lucide-react";
import { motion, useInView, useReducedMotion } from "motion/react";
import {
type KeyboardEvent,
memo,
useCallback,
useId,
useMemo,
useRef,
useState,
} from "react";
import { Tooltip } from "@/components/motion/tooltip";
import { SPRING_PANEL } from "@/lib/ease";
import { useDismiss } from "@/lib/hooks/use-dismiss";
import { useHoverGesture } from "@/lib/hooks/use-hover-gesture";
import { useTapGesture } from "@/lib/hooks/use-tap-gesture";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export type Team = {
name: string;
/**
* Any square image URL — club crest, org mark, player photo. Wins over `code`.
* Drawn as-is on the node's card-colored disc, so a transparent-background
* mark inked for one theme disappears in the other: ship artwork that reads on
* both, or pick the URL yourself from your theme state.
*/
logo?: string;
/** ISO 3166-1 alpha-2 code, loaded from flagcdn.com (England is gb-eng). Used when `logo` is absent. */
code?: string;
};
export type MatchSide = {
team: Team | null;
score: number | null;
/** Present on both sides to render shootout scores — 1 (3). */
penalties?: number | null;
};
/** Structurally compatible with the knockout bracket's Match, minus the fields
* the wheel never draws (date, time, status). */
export type Match = {
id: string;
home: MatchSide;
away: MatchSide;
winner?: "home" | "away";
};
export type Round = {
/** Read out with the match in tooltips and the screen-reader list. */
name: string;
matches: Match[];
};
export interface KnockoutWheelProps {
/**
* The whole draw, ordered widest round first — the same array the knockout
* bracket takes. Any single-elimination tournament fits: each round holds half
* the matches of the one before it (16 → 8 → 4 → 2 → 1) and `rounds[r].matches[k]`
* is fed by matches `2k` and `2k + 1` of the round before it. Two rounds are
* enough; the wheel grows a ring per round and sizes itself to the rim.
*/
rounds: Round[];
/**
* Index of the outermost round to draw. Earlier rounds are dropped and the
* kept round's own teams become the rim, so `1` on a 32-team draw opens at the
* Round of 16. Defaults to 0 (the whole tree); clamped to the valid range.
*/
initialRound?: number;
className?: string;
}
const SIZE = 760;
const CENTER = SIZE / 2;
// Solid trophy glyph drawn in a 24-unit box.
const TROPHY_SIZE = 24;
const TROPHY_PATH =
"M5 1h12v3h3a2 2 0 0 1 2 2c0 3.3-2.2 5.6-5.2 6A6 6 0 0 1 12 15a6 6 0 0 1-4.8-2.9C4.2 11.6 2 9.3 2 6a2 2 0 0 1 2-2h1V1Zm0 5H4c0 1.9 1.1 3.2 2.6 3.7A12 12 0 0 1 5 6Zm14 0h-1a12 12 0 0 1-1.6 3.7C17.9 9.2 19 7.9 19 6ZM9 16.5h6V19h2.5v2h-11v-2H9v-2.5Z";
// Clears the hub's top edge. The hub's two feeders sit on the horizontal, so
// the space directly above it is always free.
const TROPHY_GAP = 6;
// Outermost ring. The remaining 62px of the box absorbs the largest node plus
// its ring stroke, so nothing clips at the viewBox edge.
const OUTER_R = 318;
const HUB_R = 34;
// Nodes grow outward so the crowded outer ring still reads at small sizes.
const NODE_MIN = 14.2;
const NODE_STEP = 2.2;
// Initials floor, in viewBox units. The stage never goes below 32rem against a
// 760-unit box (scale ~0.674), so 15 units is ~10px on screen — under that, two
// letters are a smudge. `node.r * 0.8` alone puts the inner ring at 7.6px.
const INITIALS_MIN = 15;
// Siblings pull slightly toward their parent, opening a lane between subtrees.
const SIBLING_GAP = 0.9;
// Puts the hub's two feeders on the horizontal, where there's room for them.
const HUB_ANGLE = 90;
// Module scope so the memoized marks keep a stable transition identity.
const DIM_TRANSITION = { duration: 0.18 } as const;
const NO_TRANSITION = { duration: 0 } as const;
// Math.sin/cos are implementation-defined down in the last digits, so the SSR
// engine and the browser disagree and React reports a hydration mismatch on
// every coordinate. Quantizing well below sub-pixel makes both agree exactly.
const quantize = (n: number) => Math.round(n * 1e3) / 1e3;
const polar = (radius: number, deg: number) => {
const rad = (deg * Math.PI) / 180;
return {
x: quantize(CENTER + radius * Math.cos(rad)),
y: quantize(CENTER + radius * Math.sin(rad)),
};
};
const point = (radius: number, deg: number) => {
const { x, y } = polar(radius, deg);
return `${x.toFixed(2)} ${y.toFixed(2)}`;
};
// Fixed precision so the server and client render byte-identical style strings.
// Raw floats serialize differently across the two and trip a hydration mismatch.
const pct = (value: number) => `${((value / SIZE) * 100).toFixed(4)}%`;
type WheelNode = {
id: string;
parentId: string | null;
depth: number;
/** Position around the wheel, in degrees. Orders arrow-key navigation. */
angle: number;
x: number;
y: number;
r: number;
team: Team | null;
label: string;
/** Round the node's match belongs to; null on the rim, which holds teams. */
round: string | null;
};
type WheelLink = {
id: string;
d: string;
depth: number;
};
// Names and round labels are single ideas, so they wrap as a unit. Without this
// "Round of 16" strands a lone "16" on the next line.
const keepTogether = (text: string) => text.replace(/ /g, " ");
const teamName = (side: MatchSide) => keepTogether(side.team?.name ?? "TBD");
/** A `logo` is used as given; a country `code` loads a flag from flagcdn.com. */
const crestSrc = (team: Team) =>
team.logo ?? (team.code ? `https://flagcdn.com/w80/${team.code}.png` : null);
/** Two-letter stand-in when a team has no artwork — "Real Madrid" → RM.
* Spread, not `word[0]`: an emoji or astral first character is a surrogate pair
* and indexing it renders a replacement glyph. */
const initials = (name: string) =>
name
.split(/\s+/)
.slice(0, 2)
.map((word) => [...word][0])
.join("")
.toUpperCase();
/** Teams · score, in the order they were played. The round is prepended by the
* caller that has it, so the round list can reuse this without repeating it. */
function matchLabel(match: Match) {
const teams = `${teamName(match.home)} v ${teamName(match.away)}`;
if (match.home.score == null || match.away.score == null) return teams;
const pens =
match.home.penalties != null && match.away.penalties != null
? ` (${match.home.penalties}–${match.away.penalties} pens)`
: "";
return `${teams} · ${match.home.score}–${match.away.score}${pens}`;
}
/** Walks the match tree from the final outward, laying every node on a ring and
* splitting each parent's wedge between its two feeders. */
function buildWheel(rounds: Round[]) {
const nodes: WheelNode[] = [];
const links: WheelLink[] = [];
const layers = rounds.length;
const ringR = (depth: number) => (depth / layers) * OUTER_R;
const nodeR = (depth: number) => NODE_MIN + (depth - 1) * NODE_STEP;
// An empty or malformed catalog renders nothing rather than throwing on the
// way to the hub.
const final = rounds[layers - 1]?.matches[0];
if (!final) return { nodes, links, champion: null };
const champion = final.winner ? final[final.winner].team : null;
nodes.push({
id: final.id,
parentId: null,
depth: 0,
angle: HUB_ANGLE,
x: CENTER,
y: CENTER,
r: HUB_R,
team: champion,
label: matchLabel(final),
round: rounds[layers - 1].name,
});
// `roundIndex` is the round `match` belongs to; its two feeders live one round
// out, or, past the first round, are the two teams that played it.
const walk = (
match: Match,
roundIndex: number,
index: number,
parent: WheelNode,
angle: number,
wedge: number,
) => {
const depth = parent.depth + 1;
const radius = ringR(depth);
const r = nodeR(depth);
// The hub's two feeders sit opposite each other, so they get plain radial
// lines; an arc between them would be a half circle.
const offset = (wedge / 4) * (parent.depth === 0 ? 1 : SIBLING_GAP);
const angles = [angle - offset, angle + offset];
const sides = ["home", "away"] as const;
const children = angles.map((childAngle, side) => {
const { x, y } = polar(radius, childAngle);
const feeder =
roundIndex > 0
? rounds[roundIndex - 1].matches[2 * index + side]
: undefined;
const node: WheelNode = feeder
? {
id: feeder.id,
parentId: parent.id,
depth,
angle: childAngle,
x,
y,
r,
team: feeder.winner ? feeder[feeder.winner].team : null,
label: matchLabel(feeder),
round: rounds[roundIndex - 1].name,
}
: {
id: `${match.id}-${sides[side]}`,
parentId: parent.id,
depth,
angle: childAngle,
x,
y,
r,
team: match[sides[side]].team,
label: match[sides[side]].team?.name ?? "TBD",
round: null,
};
nodes.push(node);
return { node, angle: childAngle, feeder };
});
if (parent.depth === 0) {
for (const child of children) {
links.push({
id: `${parent.id}-${child.node.id}`,
d: `M ${point(radius, child.angle)} L ${CENTER} ${CENTER}`,
depth,
});
}
} else {
const midR = (ringR(parent.depth) + radius) / 2;
links.push({
id: `${parent.id}-arc`,
d: `M ${point(radius, angles[0])} L ${point(midR, angles[0])} A ${midR} ${midR} 0 0 1 ${point(midR, angles[1])} L ${point(radius, angles[1])}`,
depth,
});
links.push({
id: `${parent.id}-stem`,
d: `M ${point(midR, angle)} L ${point(ringR(parent.depth), angle)}`,
depth,
});
}
for (const [side, child] of children.entries()) {
if (child.feeder) {
walk(
child.feeder,
roundIndex - 1,
2 * index + side,
child.node,
child.angle,
wedge / 2,
);
}
}
};
walk(final, layers - 1, 0, nodes[0], HUB_ANGLE, 360);
return { nodes, links, champion };
}
/** Match ids from the hub down to the champion's first-round win. */
function championPath(rounds: Round[]) {
const path = new Set<string>();
let index = 0;
for (let r = rounds.length - 1; r >= 0; r--) {
const match = rounds[r].matches[index];
if (!match?.winner) return path;
path.add(match.id);
if (r === 0) path.add(`${match.id}-${match.winner}`);
index = 2 * index + (match.winner === "home" ? 0 : 1);
}
return path;
}
function TeamMark({
node,
clipId,
lit,
dimmed,
loadFlag,
transition,
}: {
node: WheelNode;
clipId: string;
lit: boolean;
dimmed: boolean;
loadFlag: boolean;
transition: object;
}) {
// The failed URL, not a boolean: a corrected logo on the same node should be
// tried again rather than stay initials for the life of the wheel.
const [failedSrc, setFailedSrc] = useState<string | null>(null);
const resolved = node.team ? crestSrc(node.team) : null;
const src = resolved === failedSrc ? null : resolved;
const showFlag = src != null && loadFlag;
// A square logo is fitted whole; a 4:3 flag is cropped to fill the disc.
const box = node.team?.logo
? { w: node.r * 1.44, h: node.r * 1.44, fit: "xMidYMid meet" }
: { w: node.r * 2.68, h: node.r * 2, fit: "xMidYMid slice" };
// Dimming rides on the mark itself rather than a scrim tinted with the page
// background, so the wheel recedes correctly on any surface it's dropped on.
const fade = { opacity: dimmed ? 0.38 : 1 };
return (
<>
{/* Stays opaque at every state: links are routed underneath and would
otherwise read straight through the flag. */}
<circle cx={node.x} cy={node.y} r={node.r} className="fill-card" />
{showFlag && node.team ? (
<>
<clipPath id={clipId}>
<circle cx={node.x} cy={node.y} r={node.r} />
</clipPath>
{/* Plain <image> — flags from flagcdn.com, logos from wherever you host
them. A 4:3 flag is cropped to fill the disc; a logo is fitted whole
inside it, since a crest cropped to a circle loses its shape. */}
<motion.image
href={src}
x={node.x - box.w / 2}
y={node.y - box.h / 2}
width={box.w}
height={box.h}
clipPath={`url(#${clipId})`}
preserveAspectRatio={box.fit}
initial={false}
animate={fade}
transition={transition}
onError={() => setFailedSrc(src)}
/>
</>
) : node.team ? (
// No artwork on this team — initials keep the ring readable.
<motion.text
x={node.x}
y={node.y}
textAnchor="middle"
dominantBaseline="central"
fontSize={Math.max(node.r * 0.8, INITIALS_MIN)}
initial={false}
animate={fade}
transition={transition}
className="fill-muted-foreground font-semibold"
>
{initials(node.team.name)}
</motion.text>
) : (
// Same shield the knockout bracket uses for a TBD slot, so an
// undecided place reads identically across both fixture styles.
<motion.g initial={false} animate={fade} transition={transition}>
<Shield
x={node.x - node.r * 0.7}
y={node.y - node.r * 0.7}
width={node.r * 1.4}
height={node.r * 1.4}
className="fill-current text-muted-foreground/50"
/>
</motion.g>
)}
<circle
cx={node.x}
cy={node.y}
r={node.r}
fill="none"
strokeWidth={lit ? 2 : 1}
className={lit ? "stroke-foreground" : "stroke-border"}
/>
</>
);
}
/** Memoized so pointing at one flag re-renders two marks, not all 63. */
const WheelMark = memo(function WheelMark({
node,
isLit,
dimmed,
loadFlag,
reduce,
enter,
showTrophy,
clipId,
}: {
node: WheelNode;
isLit: boolean;
dimmed: boolean;
loadFlag: boolean;
reduce: boolean;
enter: object;
showTrophy: boolean;
clipId: string;
}) {
return (
<motion.g
initial={reduce ? false : { opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
transition={enter}
style={{ transformOrigin: `${node.x}px ${node.y}px` }}
>
{showTrophy && (
<g
transform={`translate(${CENTER - TROPHY_SIZE / 2}, ${CENTER - HUB_R - TROPHY_GAP - TROPHY_SIZE})`}
>
<path d={TROPHY_PATH} className="fill-warning" />
</g>
)}
<TeamMark
node={node}
clipId={clipId}
lit={isLit}
dimmed={dimmed}
loadFlag={loadFlag}
transition={reduce ? NO_TRANSITION : DIM_TRANSITION}
/>
</motion.g>
);
});
/** Invisible hit area over one flag: hover, tap, focus and arrow keys. */
const WheelAnchor = memo(function WheelAnchor({
node,
caption,
isTabStop,
isPinned,
canHover,
uid,
onHover,
onFocusNode,
onToggle,
onKey,
}: {
node: WheelNode;
caption: string;
isTabStop: boolean;
isPinned: boolean;
canHover: boolean;
uid: string;
onHover: (id: string | null) => void;
onFocusNode: (id: string | null) => void;
onToggle: (id: string) => void;
onKey: (node: WheelNode, key: string) => void;
}) {
const size = pct(node.r * 2);
// Capture-phase focus props, so a Tooltip cloning the child cannot overwrite
// them. Both interaction paths are always attached: iPadOS answers the hover
// query with true for a finger, so hanging the tap path off "cannot hover"
// left it unreachable on the very device it was written for. The event says
// which input arrived.
const tap = useTapGesture<boolean>();
const hover = useHoverGesture();
const trigger = (
<button
type="button"
id={`${uid}-${node.id}`}
tabIndex={isTabStop ? 0 : -1}
aria-label={caption}
onKeyDown={(event: KeyboardEvent) => {
// A key press starts a keyboard activation, which never had a pointer
// behind it: a gesture the platform took away must not be read as the
// tap behind the click this press synthesizes.
tap.drop();
if (!event.key.startsWith("Arrow")) return;
// Arrows drive the wheel here, so they must not also scroll the page.
event.preventDefault();
onKey(node, event.key);
}}
onFocusCapture={() => onFocusNode(node.id)}
onBlurCapture={() => onFocusNode(null)}
onPointerEnter={(event) => {
if (hover.enter(event)) onHover(node.id);
}}
onPointerLeave={(event) => {
if (hover.leave(event)) onHover(null);
}}
onPointerDown={(event) => {
tap.start(event, isPinned);
}}
onPointerCancel={tap.drop}
// Click, not pointerdown: a tap focuses the button first, and unpinning
// has to also drop that focus or the flag stays lit.
onClick={(event) => {
const gesture = tap.take();
// A hovering pointer lit the flag on its way in and puts it out on the
// way past; only a gesture without a hover pins one.
if (!gesture || gesture.pointerType === "mouse") return;
onToggle(node.id);
if (gesture.state) event.currentTarget.blur();
}}
// ring-foreground, not the ring token: --ring is a 10% white hairline
// that disappears over a flag. Focus has to be obvious.
className="block h-full w-full rounded-full outline-none focus-visible:ring-2 focus-visible:ring-foreground focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
);
return (
<div
className="absolute"
style={{
left: pct(node.x - node.r),
top: pct(node.y - node.r),
width: size,
height: size,
}}
>
{/* Touch never opens a Tooltip, so those devices skip mounting one per
flag and read the tapped label instead. */}
{canHover ? (
<Tooltip
content={caption}
side="top"
wrapperClassName="block h-full w-full"
className="max-w-[20rem] whitespace-normal text-balance break-words text-center"
>
{trigger}
</Tooltip>
) : (
trigger
)}
</div>
);
});
export function KnockoutWheel({
rounds,
initialRound = 0,
className,
}: KnockoutWheelProps) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const uid = useId().replace(/:/g, "");
const ref = useRef<SVGSVGElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
// 63 flags for a 32-team draw, and SVG <image> has no lazy attribute — so the
// requests wait until the wheel is nearly on screen.
const inView = useInView(ref, { once: true, margin: "300px" });
// Hover, tap and focus are tracked apart. Sharing one slot let a stray mouse
// move clear the isolation while a node still held focus, and a tap has to
// outlive the pointerleave that a finger fires the moment it lifts.
const [hovered, setHovered] = useState<string | null>(null);
const [pinned, setPinned] = useState<string | null>(null);
const [focused, setFocused] = useState<string | null>(null);
const active = hovered ?? pinned ?? focused;
// Everything downstream reads the trimmed catalog, so the kept round's own
// teams become the rim and the sr-only list matches what's drawn.
const visible = useMemo(() => {
const from = Math.min(Math.max(initialRound, 0), Math.max(rounds.length - 1, 0));
return from > 0 ? rounds.slice(from) : rounds;
}, [rounds, initialRound]);
const { nodes, links, champion } = useMemo(
() => buildWheel(visible),
[visible],
);
const winners = useMemo(() => championPath(visible), [visible]);
const activeNode = useMemo(
() => nodes.find((node) => node.id === active),
[active, nodes],
);
// Pointing at one flag isolates that flag. Only at rest does the wheel fall
// back to lighting the champion's whole run.
const lit = useMemo(
() => (activeNode ? new Set([activeNode.id]) : winners),
[activeNode, winners],
);
// Stable identity, or every mark re-renders on each pointer move.
const enter = useMemo(
() =>
reduce
? { duration: 0, opacity: { duration: 0 } }
: { ...SPRING_PANEL, opacity: { duration: 0.24 } },
[reduce],
);
// One transition object per ring, cached, so the ring-by-ring entrance delay
// survives memoization instead of allocating 63 objects per render.
const enterFor = useMemo(() => {
const cache = new Map<number, object>();
return (depth: number) => {
const hit = cache.get(depth);
if (hit) return hit;
const value = { ...enter, delay: reduce ? 0 : depth * 0.06 };
cache.set(depth, value);
return value;
};
}, [enter, reduce]);
// Tooltip is hover-only by design, so touch gets the same label anchored to
// the tapped flag. It flips to the far side near the rim so it stays on stage.
const tapped = canHover ? undefined : activeNode;
const tapAbove = tapped ? tapped.y > CENTER : false;
// Arrow keys follow the geometry: up walks toward the hub, down walks out to
// a feeder, left/right go round the ring.
const { ring, firstChild } = useMemo(() => {
const byDepth = new Map<number, WheelNode[]>();
const child = new Map<string, WheelNode>();
for (const node of nodes) {
const peers = byDepth.get(node.depth) ?? [];
peers.push(node);
byDepth.set(node.depth, peers);
if (node.parentId && !child.has(node.parentId)) {
child.set(node.parentId, node);
}
}
for (const peers of byDepth.values()) {
peers.sort((a, b) => a.angle - b.angle);
}
return { ring: byDepth, firstChild: child };
}, [nodes]);
const tabStop = activeNode?.id ?? nodes[0]?.id;
// Stable callbacks so the memoized anchors don't re-render on every hover.
const onKey = useCallback(
(node: WheelNode, key: string) => {
if (!key.startsWith("Arrow")) return;
const target =
key === "ArrowUp"
? nodes.find((peer) => peer.id === node.parentId)
: key === "ArrowDown"
? firstChild.get(node.id)
: (() => {
const peers = ring.get(node.depth) ?? [];
if (peers.length < 2) return undefined;
const at = peers.indexOf(node);
const next = key === "ArrowRight" ? at + 1 : at - 1;
return peers[(next + peers.length) % peers.length];
})();
if (!target) return;
// Focus moves; the focus handler lights the node it lands on.
document.getElementById(`${uid}-${target.id}`)?.focus();
},
[nodes, ring, firstChild, uid],
);
const onToggle = useCallback(
(id: string) => setPinned((current) => (current === id ? null : id)),
[],
);
// A tap on another flag hands the isolation over rather than ending it.
const onFlag = useCallback(
(target: Element) =>
Boolean(stageRef.current?.contains(target) && target.closest("button")),
[],
);
// A finger never leaves the flag it lit, so the isolation would hold for good
// — and bare stage reports no pointer event of its own to end it. The next
// pointerdown that isn't on a flag stands in for the pointer leaving; it is
// consumed, since a wheel spanning the viewport makes tapping past it the
// natural way out and there is no reason for that tap to do anything else.
// Only a pinned flag arms this: a mouse ends its own hover, and the browser
// drops focus on its own.
const unpin = useCallback(() => {
setPinned(null);
const focus = document.activeElement;
if (focus instanceof HTMLElement && stageRef.current?.contains(focus)) {
focus.blur();
}
}, []);
useDismiss(pinned !== null, unpin, null, {
behavior: "consume",
ignore: onFlag,
});
// Round · teams · score for a decided match; a rim node is just a team. A slot
// whose team isn't known yet reads TBD, matching the shield drawn in its place.
const captions = useMemo(
() =>
new Map(
nodes.map((node) => [
node.id,
node.team == null
? "TBD"
: node.round
? `${keepTogether(node.round)} · ${node.label}`
: node.team.name,
]),
),
[nodes],
);
return (
<div
className={cn(
"w-full max-w-full overflow-x-auto overscroll-x-contain",
className,
)}
>
{/* Below the min width the rim's marks collapse too small to tell apart or
tap, so the wheel holds its size and pans instead. The floor is fixed,
not rim-derived: node radius grows with depth, so a shallower draw has
*smaller* marks and needs the width more, not less. */}
<div
ref={stageRef}
className="relative mx-auto w-full min-w-[32rem] max-w-[34rem]"
>
<svg
ref={ref}
viewBox={`0 0 ${SIZE} ${SIZE}`}
role="img"
aria-label={`Tournament wheel${champion ? `, won by ${champion.name}` : ""}`}
className="h-auto w-full touch-manipulation"
>
<defs>
{/* Warm halo marking the champion. Kept faint: --warning is saturated
enough that anything stronger drowns the connectors under it. */}
<radialGradient id={`${uid}-glow`}>
<stop offset="0%" stopColor="var(--color-warning)" stopOpacity="0.14" />
<stop offset="45%" stopColor="var(--color-warning)" stopOpacity="0.04" />
<stop offset="100%" stopColor="var(--color-warning)" stopOpacity="0" />
</radialGradient>
</defs>
{champion && (
<circle
cx={CENTER}
cy={CENTER}
r={OUTER_R * 0.62}
fill={`url(#${uid}-glow)`}
/>
)}
{/* role="img" on the svg prunes descendants from the accessibility tree,
so the structure needs no aria-hidden of its own. */}
<g>
{links.map((link) => (
<motion.path
key={link.id}
d={link.d}
fill="none"
initial={reduce ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: link.depth * 0.06 }}
className="stroke-border-strong"
/>
))}
</g>
{nodes.map((node) => (
<WheelMark
key={node.id}
node={node}
isLit={lit.has(node.id)}
dimmed={lit.size > 0 && !lit.has(node.id)}
loadFlag={inView}
reduce={Boolean(reduce)}
enter={enterFor(node.depth)}
showTrophy={node.depth === 0 && champion != null}
clipId={`${uid}-clip-${node.id}`}
/>
))}
</svg>
{/* An SVG <g> can't anchor a Tooltip, so each flag gets an invisible
HTML hit area laid over it in percentage units, which track the
wheel as it scales. */}
{nodes.map((node) => (
<WheelAnchor
key={node.id}
node={node}
caption={captions.get(node.id) ?? ""}
isTabStop={node.id === tabStop}
isPinned={node.id === pinned}
canHover={canHover}
uid={uid}
onHover={setHovered}
onFocusNode={setFocused}
onToggle={onToggle}
onKey={onKey}
/>
))}
{tapped && (
<motion.p
initial={reduce ? false : { opacity: 0, scale: 0.94 }}
animate={{ opacity: 1, scale: 1 }}
transition={reduce ? NO_TRANSITION : DIM_TRANSITION}
style={{
left: pct(tapped.x),
top: pct(tapped.y + (tapAbove ? -tapped.r : tapped.r)),
}}
className={cn(
"pointer-events-none absolute z-10 w-max max-w-[min(18rem,80vw)] -translate-x-1/2 text-balance break-words rounded-lg border border-border bg-background px-2.5 py-1 text-center text-xs font-medium text-foreground shadow-lg",
tapAbove ? "-translate-y-[calc(100%+8px)]" : "translate-y-2",
)}
>
{captions.get(tapped.id)}
</motion.p>
)}
</div>
{/* role="img" prunes the svg's descendants, so every result is restated
here for screen readers. */}
{/* Named lists, not <section aria-label> — a named section is a landmark,
and five of those would crowd real page landmarks. */}
<div className="sr-only">
{visible
.slice()
.reverse()
.map((round) => (
<ul key={round.name} aria-label={round.name}>
{round.matches.map((match) => (
<li key={match.id}>{matchLabel(match)}</li>
))}
</ul>
))}
</div>
</div>
);
}
// ── Sample data ──────────────────────────────────────────────────────────────
// A finished 32-team cup, here to demo the shape. Swap it for your own
// tournament. Rounds run widest first and each holds half as many matches as the
// one before it (16 → 8 → 4 → 2 → 1); `matches[k]` of a round is fed by matches
// `2k` and `2k + 1` of the round before it, which is what pairs the branches.
// Any draw works: pass fewer rounds for a smaller cup, give teams a `logo`
// instead of a country `code`, or neither for initials. The knockout bracket
// takes the same array, so one dataset feeds both fixture styles.
export const TEAMS = {
spain: { name: "Spain", code: "es" },
japan: { name: "Japan", code: "jp" },
netherlands: { name: "Netherlands", code: "nl" },
portugal: { name: "Portugal", code: "pt" },
england: { name: "England", code: "gb-eng" },
uruguay: { name: "Uruguay", code: "uy" },
croatia: { name: "Croatia", code: "hr" },
brazil: { name: "Brazil", code: "br" },
france: { name: "France", code: "fr" },
morocco: { name: "Morocco", code: "ma" },
belgium: { name: "Belgium", code: "be" },
italy: { name: "Italy", code: "it" },
argentina: { name: "Argentina", code: "ar" },
mexico: { name: "Mexico", code: "mx" },
germany: { name: "Germany", code: "de" },
norway: { name: "Norway", code: "no" },
costaRica: { name: "Costa Rica", code: "cr" },
serbia: { name: "Serbia", code: "rs" },
ecuador: { name: "Ecuador", code: "ec" },
ghana: { name: "Ghana", code: "gh" },
wales: { name: "Wales", code: "gb-wls" },
canada: { name: "Canada", code: "ca" },
denmark: { name: "Denmark", code: "dk" },
cameroon: { name: "Cameroon", code: "cm" },
poland: { name: "Poland", code: "pl" },
senegal: { name: "Senegal", code: "sn" },
tunisia: { name: "Tunisia", code: "tn" },
switzerland: { name: "Switzerland", code: "ch" },
peru: { name: "Peru", code: "pe" },
qatar: { name: "Qatar", code: "qa" },
sweden: { name: "Sweden", code: "se" },
austria: { name: "Austria", code: "at" },
} satisfies Record<string, Team>;
export const ROUNDS: Round[] = [
{
name: "Round of 32",
matches: [
{
id: "w-r32-1",
home: { team: TEAMS.spain, score: 3 },
away: { team: TEAMS.costaRica, score: 0 },
winner: "home",
},
{
id: "w-r32-2",
home: { team: TEAMS.japan, score: 2 },
away: { team: TEAMS.serbia, score: 1 },
winner: "home",
},
{
id: "w-r32-3",
home: { team: TEAMS.netherlands, score: 2 },
away: { team: TEAMS.ecuador, score: 0 },
winner: "home",
},
{
id: "w-r32-4",
home: { team: TEAMS.ghana, score: 2 },
away: { team: TEAMS.portugal, score: 3 },
winner: "away",
},
{
id: "w-r32-5",
home: { team: TEAMS.england, score: 4 },
away: { team: TEAMS.wales, score: 0 },
winner: "home",
},
{
id: "w-r32-6",
home: { team: TEAMS.canada, score: 0 },
away: { team: TEAMS.uruguay, score: 2 },
winner: "away",
},
{
id: "w-r32-7",
home: { team: TEAMS.croatia, score: 1 },
away: { team: TEAMS.denmark, score: 0 },
winner: "home",
},
{
id: "w-r32-8",
home: { team: TEAMS.brazil, score: 3 },
away: { team: TEAMS.cameroon, score: 1 },
winner: "home",
},
{
id: "w-r32-9",
home: { team: TEAMS.france, score: 2 },
away: { team: TEAMS.poland, score: 1 },
winner: "home",
},
{
id: "w-r32-10",
home: { team: TEAMS.senegal, score: 0 },
away: { team: TEAMS.morocco, score: 1 },
winner: "away",
},
{
id: "w-r32-11",
home: { team: TEAMS.belgium, score: 2 },
away: { team: TEAMS.tunisia, score: 0 },
winner: "home",
},
{
id: "w-r32-12",
home: { team: TEAMS.switzerland, score: 1 },
away: { team: TEAMS.italy, score: 3 },
winner: "away",
},
{
id: "w-r32-13",
home: { team: TEAMS.argentina, score: 2 },
away: { team: TEAMS.peru, score: 0 },
winner: "home",
},
{
id: "w-r32-14",
home: { team: TEAMS.qatar, score: 0 },
away: { team: TEAMS.mexico, score: 1 },
winner: "away",
},
{
id: "w-r32-15",
home: { team: TEAMS.germany, score: 4 },
away: { team: TEAMS.sweden, score: 2 },
winner: "home",
},
{
id: "w-r32-16",
home: { team: TEAMS.austria, score: 1 },
away: { team: TEAMS.norway, score: 2 },
winner: "away",
},
],
},
{
name: "Round of 16",
matches: [
{
id: "w-r16-1",
home: { team: TEAMS.spain, score: 2 },
away: { team: TEAMS.japan, score: 0 },
winner: "home",
},
{
id: "w-r16-2",
home: { team: TEAMS.netherlands, score: 1 },
away: { team: TEAMS.portugal, score: 3 },
winner: "away",
},
{
id: "w-r16-3",
home: { team: TEAMS.england, score: 2 },
away: { team: TEAMS.uruguay, score: 1 },
winner: "home",
},
{
id: "w-r16-4",
home: { team: TEAMS.croatia, score: 0 },
away: { team: TEAMS.brazil, score: 1 },
winner: "away",
},
{
id: "w-r16-5",
home: { team: TEAMS.france, score: 3 },
away: { team: TEAMS.morocco, score: 1 },
winner: "home",
},
{
id: "w-r16-6",
home: { team: TEAMS.belgium, score: 1 },
away: { team: TEAMS.italy, score: 2 },
winner: "away",
},
{
id: "w-r16-7",
home: { team: TEAMS.argentina, score: 2 },
away: { team: TEAMS.mexico, score: 0 },
winner: "home",
},
{
id: "w-r16-8",
home: { team: TEAMS.germany, score: 1, penalties: 4 },
away: { team: TEAMS.norway, score: 1, penalties: 2 },
winner: "home",
},
],
},
{
name: "Quarter-finals",
matches: [
{
id: "w-qf-1",
home: { team: TEAMS.spain, score: 1 },
away: { team: TEAMS.portugal, score: 0 },
winner: "home",
},
{
id: "w-qf-2",
home: { team: TEAMS.england, score: 2 },
away: { team: TEAMS.brazil, score: 3 },
winner: "away",
},
{
id: "w-qf-3",
home: { team: TEAMS.france, score: 2 },
away: { team: TEAMS.italy, score: 1 },
winner: "home",
},
{
id: "w-qf-4",
home: { team: TEAMS.argentina, score: 3 },
away: { team: TEAMS.germany, score: 1 },
winner: "home",
},
],
},
{
name: "Semi-finals",
matches: [
{
id: "w-sf-1",
home: { team: TEAMS.spain, score: 2 },
away: { team: TEAMS.brazil, score: 1 },
winner: "home",
},
{
id: "w-sf-2",
home: { team: TEAMS.france, score: 0, penalties: 3 },
away: { team: TEAMS.argentina, score: 0, penalties: 4 },
winner: "away",
},
],
},
{
name: "Final",
matches: [
{
id: "w-f-1",
home: { team: TEAMS.spain, score: 2 },
away: { team: TEAMS.argentina, score: 1 },
winner: "home",
},
],
},
];
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// 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;
"use client";
import { type RefObject, useEffect } from "react";
/**
* What the dismissing gesture does to the control it landed on.
*
* `"pass-through"` is the platform norm (native popover light-dismiss): the
* tap closes the overlay *and* activates whatever was under it. Use
* `"consume"` where the open overlay sits over or beside controls that would
* be costly to trigger by accident — the dismissal then swallows the
* activation too, so the gesture only closes.
*/
export type DismissBehavior = "pass-through" | "consume";
export interface DismissOptions {
/** Default `"pass-through"`. */
behavior?: DismissBehavior;
/** Dismiss on Escape as well. Default true. */
escape?: boolean;
/** Return true for an outside target that should *not* dismiss. Must be stable. */
ignore?: (target: Element) => boolean;
}
/**
* What every currently open dismiss scope counts as inside itself. A consumed
* dismissal reads this to tell a stray gesture from one that belongs to an
* overlay in front of it: overlays have no shared z-order to consult, but the
* one the gesture landed in has said as much by registering it.
*/
const openScopes = new Set<(target: Element) => boolean>();
function claimedByAnotherScope(
self: (target: Element) => boolean,
target: Element,
) {
for (const scope of openScopes) {
if (scope !== self && scope(target)) return true;
}
return false;
}
// preventDefault on pointerdown does not suppress the click that follows, so
// consuming a gesture means swallowing that click itself. The swallower
// deliberately outlives the effect that installed it — the dismissal it
// belongs to has already unmounted or re-rendered by the time the click lands.
// It releases on that click, or on the next gesture if the pointer is dragged
// away and no click ever arrives, so it can never eat a later one. A keydown
// releases it too: a gesture that ends with neither a click nor a cancel would
// otherwise leave it armed, and the click Enter synthesizes on some focused
// control is not the one this dismissal was owed.
function consumeActivation(source: Event) {
const swallow = (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
release();
};
const restart = (event: Event) => {
if (event !== source) release();
};
const release = () => {
window.removeEventListener("click", swallow, true);
window.removeEventListener("pointerdown", restart, true);
window.removeEventListener("pointercancel", restart, true);
window.removeEventListener("keydown", release, true);
};
window.addEventListener("click", swallow, true);
window.addEventListener("pointerdown", restart, true);
window.addEventListener("pointercancel", restart, true);
window.addEventListener("keydown", release, true);
}
/**
* Close an open overlay on Escape or a pointerdown outside `ref`. Pass `null`
* for `ref` when what counts as inside isn't one element, and say so with
* `ignore` instead.
*
* The pointerdown listener is capture-phase: a bubble-phase one is blinded by
* any handler in between that stops propagation, and an overlay cannot know
* what it is layered over. `onDismiss` and `ignore` must be stable (wrap in
* useCallback) so the listeners aren't re-bound every render while open.
*/
export function useDismiss(
open: boolean,
onDismiss: () => void,
ref: RefObject<HTMLElement | null> | null,
{
behavior = "pass-through",
escape: dismissOnEscape = true,
ignore,
}: DismissOptions = {},
) {
useEffect(() => {
if (!open) return;
const inside = (target: Element) =>
Boolean(ref?.current?.contains(target)) || Boolean(ignore?.(target));
const onKey = (event: KeyboardEvent) => {
if (dismissOnEscape && event.key === "Escape") onDismiss();
};
const onPointer = (event: PointerEvent) => {
const target = event.target as Element | null;
if (!target || inside(target)) return;
// Outside this overlay, but inside one that is also open: the gesture is
// that overlay's to answer, and swallowing its click from behind would
// cost the user the control they actually aimed at.
if (behavior === "consume" && !claimedByAnotherScope(inside, target)) {
consumeActivation(event);
}
onDismiss();
};
openScopes.add(inside);
window.addEventListener("keydown", onKey);
window.addEventListener("pointerdown", onPointer, true);
return () => {
openScopes.delete(inside);
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer, true);
};
}, [open, onDismiss, ref, behavior, dismissOnEscape, ignore]);
}
"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;
}
"use client";
import { useMemo, useRef } from "react";
import { isHoveringPointer } from "@/lib/touch";
interface BoundaryEvent {
pointerId: number;
pointerType: string;
buttons: number;
}
export interface HoverGesture {
/** True when this enter starts a hover: the pointer arrived resting, not pressing. */
enter: (event: BoundaryEvent) => boolean;
/** True when this leave ends a hover that entered as one. */
leave: (event: BoundaryEvent) => boolean;
}
/**
* Pairs a surface's enter with its leave, per pointer.
*
* `isHoveringPointer` answers the question the *enter* asks — is this pointer
* resting on the surface or pressing it — and both boundary cases go wrong if
* the leave is asked the same question again:
*
* - A pen with no hover never rests. It arrives in contact, taps, and the spec
* then requires its boundary events after `pointerup`, so the leave carries
* `buttons: 0` and reads as a mouse gliding off. Hover teardown then undid
* the tap — the panel the pen had just opened closed under it.
* - A mouse pressed on the surface and dragged off leaves with `buttons: 1`.
* Skipping teardown there strands the surface open: the release happens
* outside, and no second leave ever comes.
*
* So the state a hover holds is released by the pointer that took it, whatever
* the buttons say at the boundary, and a pointer that arrived in contact never
* took it in the first place. Contact is the exception tracked here, not
* hover: a leave from a pointer this surface never saw enter — mounted under
* the cursor, say — still counts, since the alternative is state with no way
* out.
*/
export function useHoverGesture(): HoverGesture {
const contact = useRef(new Set<number>());
return useMemo(
() => ({
enter: (event) => {
if (isHoveringPointer(event)) {
contact.current.delete(event.pointerId);
return true;
}
contact.current.add(event.pointerId);
return false;
},
leave: (event) => {
const arrivedInContact = contact.current.delete(event.pointerId);
return !arrivedInContact && event.pointerType !== "touch";
},
}),
[],
);
}
"use client";
import { useMemo, useRef } from "react";
/** What a pointerdown recorded, read back by the click that ends its gesture. */
export interface TapRecord<S> {
/** Which input started the gesture. */
pointerType: string;
/** What the surface was showing when it started. */
state: S;
}
export interface TapGesture<S> {
/** Record the gesture a pointerdown starts, with the state it starts in. */
start: (event: { pointerType: string }, state: S) => void;
/** Read the record and clear it. `null` when no pointer is behind this click. */
take: () => TapRecord<S> | null;
/** Drop the record: this gesture will never spend it on a click. */
drop: () => void;
}
/**
* The pointer gesture behind a click, recorded where the click cannot report
* it. A `click` carries no `pointerType` in the engines that matter, so the
* `pointerdown` before it is the only thing that says which input activated
* the control — and whether one did at all, since keyboard activation
* synthesizes a click with no pointer behind it.
*
* State goes in with the record because a click reports that no better: a
* browser that focuses a control on contact can open the very panel the tap
* was meant to open, and reading "is it open" at click time then undoes it.
* What the gesture started against is what it acts on.
*
* The record is spent by one click and dropped by everything else, because a
* record that outlives its gesture is worse than none:
*
* - A scroll or an OS gesture takes the touch away — `pointercancel`, no click
* ever — and the finger would sit in the record until some later click.
* - That later click is often `Enter` on a keyboard, which arrives with no
* pointerdown of its own and would inherit the abandoned finger. A keydown
* is the start of a keyboard activation and never part of a tap, so it drops
* the record too.
*
* Both ends have to be wired by the surface: `drop` on `onPointerCancel` and
* on `onKeyDown`.
*/
export function useTapGesture<S>(): TapGesture<S> {
const record = useRef<TapRecord<S> | null>(null);
return useMemo(
() => ({
start: (event, state) => {
record.current = { pointerType: event.pointerType, state };
},
take: () => {
const spent = record.current;
record.current = null;
return spent;
},
drop: () => {
record.current = null;
},
}),
[],
);
}
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
Copy the source code
"use client";
// beui.dev/components/blocks/knockout-bracket
import { Shield } from "lucide-react";
import { motion, useInView, useReducedMotion } from "motion/react";
import {
type KeyboardEvent,
memo,
useCallback,
useId,
useMemo,
useRef,
useState,
} from "react";
import { Tooltip } from "@/components/motion/tooltip";
import { SPRING_PANEL } from "@/lib/ease";
import { useDismiss } from "@/lib/hooks/use-dismiss";
import { useHoverGesture } from "@/lib/hooks/use-hover-gesture";
import { useTapGesture } from "@/lib/hooks/use-tap-gesture";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export type Team = {
name: string;
/**
* Any square image URL — club crest, org mark, player photo. Wins over `code`.
* Drawn as-is on the node's card-colored disc, so a transparent-background
* mark inked for one theme disappears in the other: ship artwork that reads on
* both, or pick the URL yourself from your theme state.
*/
logo?: string;
/** ISO 3166-1 alpha-2 code, loaded from flagcdn.com (England is gb-eng). Used when `logo` is absent. */
code?: string;
};
export type MatchSide = {
team: Team | null;
score: number | null;
/** Present on both sides to render shootout scores — 1 (3). */
penalties?: number | null;
};
/** Structurally compatible with the knockout bracket's Match, minus the fields
* the wheel never draws (date, time, status). */
export type Match = {
id: string;
home: MatchSide;
away: MatchSide;
winner?: "home" | "away";
};
export type Round = {
/** Read out with the match in tooltips and the screen-reader list. */
name: string;
matches: Match[];
};
export interface KnockoutWheelProps {
/**
* The whole draw, ordered widest round first — the same array the knockout
* bracket takes. Any single-elimination tournament fits: each round holds half
* the matches of the one before it (16 → 8 → 4 → 2 → 1) and `rounds[r].matches[k]`
* is fed by matches `2k` and `2k + 1` of the round before it. Two rounds are
* enough; the wheel grows a ring per round and sizes itself to the rim.
*/
rounds: Round[];
/**
* Index of the outermost round to draw. Earlier rounds are dropped and the
* kept round's own teams become the rim, so `1` on a 32-team draw opens at the
* Round of 16. Defaults to 0 (the whole tree); clamped to the valid range.
*/
initialRound?: number;
className?: string;
}
const SIZE = 760;
const CENTER = SIZE / 2;
// Solid trophy glyph drawn in a 24-unit box.
const TROPHY_SIZE = 24;
const TROPHY_PATH =
"M5 1h12v3h3a2 2 0 0 1 2 2c0 3.3-2.2 5.6-5.2 6A6 6 0 0 1 12 15a6 6 0 0 1-4.8-2.9C4.2 11.6 2 9.3 2 6a2 2 0 0 1 2-2h1V1Zm0 5H4c0 1.9 1.1 3.2 2.6 3.7A12 12 0 0 1 5 6Zm14 0h-1a12 12 0 0 1-1.6 3.7C17.9 9.2 19 7.9 19 6ZM9 16.5h6V19h2.5v2h-11v-2H9v-2.5Z";
// Clears the hub's top edge. The hub's two feeders sit on the horizontal, so
// the space directly above it is always free.
const TROPHY_GAP = 6;
// Outermost ring. The remaining 62px of the box absorbs the largest node plus
// its ring stroke, so nothing clips at the viewBox edge.
const OUTER_R = 318;
const HUB_R = 34;
// Nodes grow outward so the crowded outer ring still reads at small sizes.
const NODE_MIN = 14.2;
const NODE_STEP = 2.2;
// Initials floor, in viewBox units. The stage never goes below 32rem against a
// 760-unit box (scale ~0.674), so 15 units is ~10px on screen — under that, two
// letters are a smudge. `node.r * 0.8` alone puts the inner ring at 7.6px.
const INITIALS_MIN = 15;
// Siblings pull slightly toward their parent, opening a lane between subtrees.
const SIBLING_GAP = 0.9;
// Puts the hub's two feeders on the horizontal, where there's room for them.
const HUB_ANGLE = 90;
// Module scope so the memoized marks keep a stable transition identity.
const DIM_TRANSITION = { duration: 0.18 } as const;
const NO_TRANSITION = { duration: 0 } as const;
// Math.sin/cos are implementation-defined down in the last digits, so the SSR
// engine and the browser disagree and React reports a hydration mismatch on
// every coordinate. Quantizing well below sub-pixel makes both agree exactly.
const quantize = (n: number) => Math.round(n * 1e3) / 1e3;
const polar = (radius: number, deg: number) => {
const rad = (deg * Math.PI) / 180;
return {
x: quantize(CENTER + radius * Math.cos(rad)),
y: quantize(CENTER + radius * Math.sin(rad)),
};
};
const point = (radius: number, deg: number) => {
const { x, y } = polar(radius, deg);
return `${x.toFixed(2)} ${y.toFixed(2)}`;
};
// Fixed precision so the server and client render byte-identical style strings.
// Raw floats serialize differently across the two and trip a hydration mismatch.
const pct = (value: number) => `${((value / SIZE) * 100).toFixed(4)}%`;
type WheelNode = {
id: string;
parentId: string | null;
depth: number;
/** Position around the wheel, in degrees. Orders arrow-key navigation. */
angle: number;
x: number;
y: number;
r: number;
team: Team | null;
label: string;
/** Round the node's match belongs to; null on the rim, which holds teams. */
round: string | null;
};
type WheelLink = {
id: string;
d: string;
depth: number;
};
// Names and round labels are single ideas, so they wrap as a unit. Without this
// "Round of 16" strands a lone "16" on the next line.
const keepTogether = (text: string) => text.replace(/ /g, " ");
const teamName = (side: MatchSide) => keepTogether(side.team?.name ?? "TBD");
/** A `logo` is used as given; a country `code` loads a flag from flagcdn.com. */
const crestSrc = (team: Team) =>
team.logo ?? (team.code ? `https://flagcdn.com/w80/${team.code}.png` : null);
/** Two-letter stand-in when a team has no artwork — "Real Madrid" → RM.
* Spread, not `word[0]`: an emoji or astral first character is a surrogate pair
* and indexing it renders a replacement glyph. */
const initials = (name: string) =>
name
.split(/\s+/)
.slice(0, 2)
.map((word) => [...word][0])
.join("")
.toUpperCase();
/** Teams · score, in the order they were played. The round is prepended by the
* caller that has it, so the round list can reuse this without repeating it. */
function matchLabel(match: Match) {
const teams = `${teamName(match.home)} v ${teamName(match.away)}`;
if (match.home.score == null || match.away.score == null) return teams;
const pens =
match.home.penalties != null && match.away.penalties != null
? ` (${match.home.penalties}–${match.away.penalties} pens)`
: "";
return `${teams} · ${match.home.score}–${match.away.score}${pens}`;
}
/** Walks the match tree from the final outward, laying every node on a ring and
* splitting each parent's wedge between its two feeders. */
function buildWheel(rounds: Round[]) {
const nodes: WheelNode[] = [];
const links: WheelLink[] = [];
const layers = rounds.length;
const ringR = (depth: number) => (depth / layers) * OUTER_R;
const nodeR = (depth: number) => NODE_MIN + (depth - 1) * NODE_STEP;
// An empty or malformed catalog renders nothing rather than throwing on the
// way to the hub.
const final = rounds[layers - 1]?.matches[0];
if (!final) return { nodes, links, champion: null };
const champion = final.winner ? final[final.winner].team : null;
nodes.push({
id: final.id,
parentId: null,
depth: 0,
angle: HUB_ANGLE,
x: CENTER,
y: CENTER,
r: HUB_R,
team: champion,
label: matchLabel(final),
round: rounds[layers - 1].name,
});
// `roundIndex` is the round `match` belongs to; its two feeders live one round
// out, or, past the first round, are the two teams that played it.
const walk = (
match: Match,
roundIndex: number,
index: number,
parent: WheelNode,
angle: number,
wedge: number,
) => {
const depth = parent.depth + 1;
const radius = ringR(depth);
const r = nodeR(depth);
// The hub's two feeders sit opposite each other, so they get plain radial
// lines; an arc between them would be a half circle.
const offset = (wedge / 4) * (parent.depth === 0 ? 1 : SIBLING_GAP);
const angles = [angle - offset, angle + offset];
const sides = ["home", "away"] as const;
const children = angles.map((childAngle, side) => {
const { x, y } = polar(radius, childAngle);
const feeder =
roundIndex > 0
? rounds[roundIndex - 1].matches[2 * index + side]
: undefined;
const node: WheelNode = feeder
? {
id: feeder.id,
parentId: parent.id,
depth,
angle: childAngle,
x,
y,
r,
team: feeder.winner ? feeder[feeder.winner].team : null,
label: matchLabel(feeder),
round: rounds[roundIndex - 1].name,
}
: {
id: `${match.id}-${sides[side]}`,
parentId: parent.id,
depth,
angle: childAngle,
x,
y,
r,
team: match[sides[side]].team,
label: match[sides[side]].team?.name ?? "TBD",
round: null,
};
nodes.push(node);
return { node, angle: childAngle, feeder };
});
if (parent.depth === 0) {
for (const child of children) {
links.push({
id: `${parent.id}-${child.node.id}`,
d: `M ${point(radius, child.angle)} L ${CENTER} ${CENTER}`,
depth,
});
}
} else {
const midR = (ringR(parent.depth) + radius) / 2;
links.push({
id: `${parent.id}-arc`,
d: `M ${point(radius, angles[0])} L ${point(midR, angles[0])} A ${midR} ${midR} 0 0 1 ${point(midR, angles[1])} L ${point(radius, angles[1])}`,
depth,
});
links.push({
id: `${parent.id}-stem`,
d: `M ${point(midR, angle)} L ${point(ringR(parent.depth), angle)}`,
depth,
});
}
for (const [side, child] of children.entries()) {
if (child.feeder) {
walk(
child.feeder,
roundIndex - 1,
2 * index + side,
child.node,
child.angle,
wedge / 2,
);
}
}
};
walk(final, layers - 1, 0, nodes[0], HUB_ANGLE, 360);
return { nodes, links, champion };
}
/** Match ids from the hub down to the champion's first-round win. */
function championPath(rounds: Round[]) {
const path = new Set<string>();
let index = 0;
for (let r = rounds.length - 1; r >= 0; r--) {
const match = rounds[r].matches[index];
if (!match?.winner) return path;
path.add(match.id);
if (r === 0) path.add(`${match.id}-${match.winner}`);
index = 2 * index + (match.winner === "home" ? 0 : 1);
}
return path;
}
function TeamMark({
node,
clipId,
lit,
dimmed,
loadFlag,
transition,
}: {
node: WheelNode;
clipId: string;
lit: boolean;
dimmed: boolean;
loadFlag: boolean;
transition: object;
}) {
// The failed URL, not a boolean: a corrected logo on the same node should be
// tried again rather than stay initials for the life of the wheel.
const [failedSrc, setFailedSrc] = useState<string | null>(null);
const resolved = node.team ? crestSrc(node.team) : null;
const src = resolved === failedSrc ? null : resolved;
const showFlag = src != null && loadFlag;
// A square logo is fitted whole; a 4:3 flag is cropped to fill the disc.
const box = node.team?.logo
? { w: node.r * 1.44, h: node.r * 1.44, fit: "xMidYMid meet" }
: { w: node.r * 2.68, h: node.r * 2, fit: "xMidYMid slice" };
// Dimming rides on the mark itself rather than a scrim tinted with the page
// background, so the wheel recedes correctly on any surface it's dropped on.
const fade = { opacity: dimmed ? 0.38 : 1 };
return (
<>
{/* Stays opaque at every state: links are routed underneath and would
otherwise read straight through the flag. */}
<circle cx={node.x} cy={node.y} r={node.r} className="fill-card" />
{showFlag && node.team ? (
<>
<clipPath id={clipId}>
<circle cx={node.x} cy={node.y} r={node.r} />
</clipPath>
{/* Plain <image> — flags from flagcdn.com, logos from wherever you host
them. A 4:3 flag is cropped to fill the disc; a logo is fitted whole
inside it, since a crest cropped to a circle loses its shape. */}
<motion.image
href={src}
x={node.x - box.w / 2}
y={node.y - box.h / 2}
width={box.w}
height={box.h}
clipPath={`url(#${clipId})`}
preserveAspectRatio={box.fit}
initial={false}
animate={fade}
transition={transition}
onError={() => setFailedSrc(src)}
/>
</>
) : node.team ? (
// No artwork on this team — initials keep the ring readable.
<motion.text
x={node.x}
y={node.y}
textAnchor="middle"
dominantBaseline="central"
fontSize={Math.max(node.r * 0.8, INITIALS_MIN)}
initial={false}
animate={fade}
transition={transition}
className="fill-muted-foreground font-semibold"
>
{initials(node.team.name)}
</motion.text>
) : (
// Same shield the knockout bracket uses for a TBD slot, so an
// undecided place reads identically across both fixture styles.
<motion.g initial={false} animate={fade} transition={transition}>
<Shield
x={node.x - node.r * 0.7}
y={node.y - node.r * 0.7}
width={node.r * 1.4}
height={node.r * 1.4}
className="fill-current text-muted-foreground/50"
/>
</motion.g>
)}
<circle
cx={node.x}
cy={node.y}
r={node.r}
fill="none"
strokeWidth={lit ? 2 : 1}
className={lit ? "stroke-foreground" : "stroke-border"}
/>
</>
);
}
/** Memoized so pointing at one flag re-renders two marks, not all 63. */
const WheelMark = memo(function WheelMark({
node,
isLit,
dimmed,
loadFlag,
reduce,
enter,
showTrophy,
clipId,
}: {
node: WheelNode;
isLit: boolean;
dimmed: boolean;
loadFlag: boolean;
reduce: boolean;
enter: object;
showTrophy: boolean;
clipId: string;
}) {
return (
<motion.g
initial={reduce ? false : { opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
transition={enter}
style={{ transformOrigin: `${node.x}px ${node.y}px` }}
>
{showTrophy && (
<g
transform={`translate(${CENTER - TROPHY_SIZE / 2}, ${CENTER - HUB_R - TROPHY_GAP - TROPHY_SIZE})`}
>
<path d={TROPHY_PATH} className="fill-warning" />
</g>
)}
<TeamMark
node={node}
clipId={clipId}
lit={isLit}
dimmed={dimmed}
loadFlag={loadFlag}
transition={reduce ? NO_TRANSITION : DIM_TRANSITION}
/>
</motion.g>
);
});
/** Invisible hit area over one flag: hover, tap, focus and arrow keys. */
const WheelAnchor = memo(function WheelAnchor({
node,
caption,
isTabStop,
isPinned,
canHover,
uid,
onHover,
onFocusNode,
onToggle,
onKey,
}: {
node: WheelNode;
caption: string;
isTabStop: boolean;
isPinned: boolean;
canHover: boolean;
uid: string;
onHover: (id: string | null) => void;
onFocusNode: (id: string | null) => void;
onToggle: (id: string) => void;
onKey: (node: WheelNode, key: string) => void;
}) {
const size = pct(node.r * 2);
// Capture-phase focus props, so a Tooltip cloning the child cannot overwrite
// them. Both interaction paths are always attached: iPadOS answers the hover
// query with true for a finger, so hanging the tap path off "cannot hover"
// left it unreachable on the very device it was written for. The event says
// which input arrived.
const tap = useTapGesture<boolean>();
const hover = useHoverGesture();
const trigger = (
<button
type="button"
id={`${uid}-${node.id}`}
tabIndex={isTabStop ? 0 : -1}
aria-label={caption}
onKeyDown={(event: KeyboardEvent) => {
// A key press starts a keyboard activation, which never had a pointer
// behind it: a gesture the platform took away must not be read as the
// tap behind the click this press synthesizes.
tap.drop();
if (!event.key.startsWith("Arrow")) return;
// Arrows drive the wheel here, so they must not also scroll the page.
event.preventDefault();
onKey(node, event.key);
}}
onFocusCapture={() => onFocusNode(node.id)}
onBlurCapture={() => onFocusNode(null)}
onPointerEnter={(event) => {
if (hover.enter(event)) onHover(node.id);
}}
onPointerLeave={(event) => {
if (hover.leave(event)) onHover(null);
}}
onPointerDown={(event) => {
tap.start(event, isPinned);
}}
onPointerCancel={tap.drop}
// Click, not pointerdown: a tap focuses the button first, and unpinning
// has to also drop that focus or the flag stays lit.
onClick={(event) => {
const gesture = tap.take();
// A hovering pointer lit the flag on its way in and puts it out on the
// way past; only a gesture without a hover pins one.
if (!gesture || gesture.pointerType === "mouse") return;
onToggle(node.id);
if (gesture.state) event.currentTarget.blur();
}}
// ring-foreground, not the ring token: --ring is a 10% white hairline
// that disappears over a flag. Focus has to be obvious.
className="block h-full w-full rounded-full outline-none focus-visible:ring-2 focus-visible:ring-foreground focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
);
return (
<div
className="absolute"
style={{
left: pct(node.x - node.r),
top: pct(node.y - node.r),
width: size,
height: size,
}}
>
{/* Touch never opens a Tooltip, so those devices skip mounting one per
flag and read the tapped label instead. */}
{canHover ? (
<Tooltip
content={caption}
side="top"
wrapperClassName="block h-full w-full"
className="max-w-[20rem] whitespace-normal text-balance break-words text-center"
>
{trigger}
</Tooltip>
) : (
trigger
)}
</div>
);
});
export function KnockoutWheel({
rounds,
initialRound = 0,
className,
}: KnockoutWheelProps) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const uid = useId().replace(/:/g, "");
const ref = useRef<SVGSVGElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
// 63 flags for a 32-team draw, and SVG <image> has no lazy attribute — so the
// requests wait until the wheel is nearly on screen.
const inView = useInView(ref, { once: true, margin: "300px" });
// Hover, tap and focus are tracked apart. Sharing one slot let a stray mouse
// move clear the isolation while a node still held focus, and a tap has to
// outlive the pointerleave that a finger fires the moment it lifts.
const [hovered, setHovered] = useState<string | null>(null);
const [pinned, setPinned] = useState<string | null>(null);
const [focused, setFocused] = useState<string | null>(null);
const active = hovered ?? pinned ?? focused;
// Everything downstream reads the trimmed catalog, so the kept round's own
// teams become the rim and the sr-only list matches what's drawn.
const visible = useMemo(() => {
const from = Math.min(Math.max(initialRound, 0), Math.max(rounds.length - 1, 0));
return from > 0 ? rounds.slice(from) : rounds;
}, [rounds, initialRound]);
const { nodes, links, champion } = useMemo(
() => buildWheel(visible),
[visible],
);
const winners = useMemo(() => championPath(visible), [visible]);
const activeNode = useMemo(
() => nodes.find((node) => node.id === active),
[active, nodes],
);
// Pointing at one flag isolates that flag. Only at rest does the wheel fall
// back to lighting the champion's whole run.
const lit = useMemo(
() => (activeNode ? new Set([activeNode.id]) : winners),
[activeNode, winners],
);
// Stable identity, or every mark re-renders on each pointer move.
const enter = useMemo(
() =>
reduce
? { duration: 0, opacity: { duration: 0 } }
: { ...SPRING_PANEL, opacity: { duration: 0.24 } },
[reduce],
);
// One transition object per ring, cached, so the ring-by-ring entrance delay
// survives memoization instead of allocating 63 objects per render.
const enterFor = useMemo(() => {
const cache = new Map<number, object>();
return (depth: number) => {
const hit = cache.get(depth);
if (hit) return hit;
const value = { ...enter, delay: reduce ? 0 : depth * 0.06 };
cache.set(depth, value);
return value;
};
}, [enter, reduce]);
// Tooltip is hover-only by design, so touch gets the same label anchored to
// the tapped flag. It flips to the far side near the rim so it stays on stage.
const tapped = canHover ? undefined : activeNode;
const tapAbove = tapped ? tapped.y > CENTER : false;
// Arrow keys follow the geometry: up walks toward the hub, down walks out to
// a feeder, left/right go round the ring.
const { ring, firstChild } = useMemo(() => {
const byDepth = new Map<number, WheelNode[]>();
const child = new Map<string, WheelNode>();
for (const node of nodes) {
const peers = byDepth.get(node.depth) ?? [];
peers.push(node);
byDepth.set(node.depth, peers);
if (node.parentId && !child.has(node.parentId)) {
child.set(node.parentId, node);
}
}
for (const peers of byDepth.values()) {
peers.sort((a, b) => a.angle - b.angle);
}
return { ring: byDepth, firstChild: child };
}, [nodes]);
const tabStop = activeNode?.id ?? nodes[0]?.id;
// Stable callbacks so the memoized anchors don't re-render on every hover.
const onKey = useCallback(
(node: WheelNode, key: string) => {
if (!key.startsWith("Arrow")) return;
const target =
key === "ArrowUp"
? nodes.find((peer) => peer.id === node.parentId)
: key === "ArrowDown"
? firstChild.get(node.id)
: (() => {
const peers = ring.get(node.depth) ?? [];
if (peers.length < 2) return undefined;
const at = peers.indexOf(node);
const next = key === "ArrowRight" ? at + 1 : at - 1;
return peers[(next + peers.length) % peers.length];
})();
if (!target) return;
// Focus moves; the focus handler lights the node it lands on.
document.getElementById(`${uid}-${target.id}`)?.focus();
},
[nodes, ring, firstChild, uid],
);
const onToggle = useCallback(
(id: string) => setPinned((current) => (current === id ? null : id)),
[],
);
// A tap on another flag hands the isolation over rather than ending it.
const onFlag = useCallback(
(target: Element) =>
Boolean(stageRef.current?.contains(target) && target.closest("button")),
[],
);
// A finger never leaves the flag it lit, so the isolation would hold for good
// — and bare stage reports no pointer event of its own to end it. The next
// pointerdown that isn't on a flag stands in for the pointer leaving; it is
// consumed, since a wheel spanning the viewport makes tapping past it the
// natural way out and there is no reason for that tap to do anything else.
// Only a pinned flag arms this: a mouse ends its own hover, and the browser
// drops focus on its own.
const unpin = useCallback(() => {
setPinned(null);
const focus = document.activeElement;
if (focus instanceof HTMLElement && stageRef.current?.contains(focus)) {
focus.blur();
}
}, []);
useDismiss(pinned !== null, unpin, null, {
behavior: "consume",
ignore: onFlag,
});
// Round · teams · score for a decided match; a rim node is just a team. A slot
// whose team isn't known yet reads TBD, matching the shield drawn in its place.
const captions = useMemo(
() =>
new Map(
nodes.map((node) => [
node.id,
node.team == null
? "TBD"
: node.round
? `${keepTogether(node.round)} · ${node.label}`
: node.team.name,
]),
),
[nodes],
);
return (
<div
className={cn(
"w-full max-w-full overflow-x-auto overscroll-x-contain",
className,
)}
>
{/* Below the min width the rim's marks collapse too small to tell apart or
tap, so the wheel holds its size and pans instead. The floor is fixed,
not rim-derived: node radius grows with depth, so a shallower draw has
*smaller* marks and needs the width more, not less. */}
<div
ref={stageRef}
className="relative mx-auto w-full min-w-[32rem] max-w-[34rem]"
>
<svg
ref={ref}
viewBox={`0 0 ${SIZE} ${SIZE}`}
role="img"
aria-label={`Tournament wheel${champion ? `, won by ${champion.name}` : ""}`}
className="h-auto w-full touch-manipulation"
>
<defs>
{/* Warm halo marking the champion. Kept faint: --warning is saturated
enough that anything stronger drowns the connectors under it. */}
<radialGradient id={`${uid}-glow`}>
<stop offset="0%" stopColor="var(--color-warning)" stopOpacity="0.14" />
<stop offset="45%" stopColor="var(--color-warning)" stopOpacity="0.04" />
<stop offset="100%" stopColor="var(--color-warning)" stopOpacity="0" />
</radialGradient>
</defs>
{champion && (
<circle
cx={CENTER}
cy={CENTER}
r={OUTER_R * 0.62}
fill={`url(#${uid}-glow)`}
/>
)}
{/* role="img" on the svg prunes descendants from the accessibility tree,
so the structure needs no aria-hidden of its own. */}
<g>
{links.map((link) => (
<motion.path
key={link.id}
d={link.d}
fill="none"
initial={reduce ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: link.depth * 0.06 }}
className="stroke-border-strong"
/>
))}
</g>
{nodes.map((node) => (
<WheelMark
key={node.id}
node={node}
isLit={lit.has(node.id)}
dimmed={lit.size > 0 && !lit.has(node.id)}
loadFlag={inView}
reduce={Boolean(reduce)}
enter={enterFor(node.depth)}
showTrophy={node.depth === 0 && champion != null}
clipId={`${uid}-clip-${node.id}`}
/>
))}
</svg>
{/* An SVG <g> can't anchor a Tooltip, so each flag gets an invisible
HTML hit area laid over it in percentage units, which track the
wheel as it scales. */}
{nodes.map((node) => (
<WheelAnchor
key={node.id}
node={node}
caption={captions.get(node.id) ?? ""}
isTabStop={node.id === tabStop}
isPinned={node.id === pinned}
canHover={canHover}
uid={uid}
onHover={setHovered}
onFocusNode={setFocused}
onToggle={onToggle}
onKey={onKey}
/>
))}
{tapped && (
<motion.p
initial={reduce ? false : { opacity: 0, scale: 0.94 }}
animate={{ opacity: 1, scale: 1 }}
transition={reduce ? NO_TRANSITION : DIM_TRANSITION}
style={{
left: pct(tapped.x),
top: pct(tapped.y + (tapAbove ? -tapped.r : tapped.r)),
}}
className={cn(
"pointer-events-none absolute z-10 w-max max-w-[min(18rem,80vw)] -translate-x-1/2 text-balance break-words rounded-lg border border-border bg-background px-2.5 py-1 text-center text-xs font-medium text-foreground shadow-lg",
tapAbove ? "-translate-y-[calc(100%+8px)]" : "translate-y-2",
)}
>
{captions.get(tapped.id)}
</motion.p>
)}
</div>
{/* role="img" prunes the svg's descendants, so every result is restated
here for screen readers. */}
{/* Named lists, not <section aria-label> — a named section is a landmark,
and five of those would crowd real page landmarks. */}
<div className="sr-only">
{visible
.slice()
.reverse()
.map((round) => (
<ul key={round.name} aria-label={round.name}>
{round.matches.map((match) => (
<li key={match.id}>{matchLabel(match)}</li>
))}
</ul>
))}
</div>
</div>
);
}
// ── Sample data ──────────────────────────────────────────────────────────────
// A finished 32-team cup, here to demo the shape. Swap it for your own
// tournament. Rounds run widest first and each holds half as many matches as the
// one before it (16 → 8 → 4 → 2 → 1); `matches[k]` of a round is fed by matches
// `2k` and `2k + 1` of the round before it, which is what pairs the branches.
// Any draw works: pass fewer rounds for a smaller cup, give teams a `logo`
// instead of a country `code`, or neither for initials. The knockout bracket
// takes the same array, so one dataset feeds both fixture styles.
export const TEAMS = {
spain: { name: "Spain", code: "es" },
japan: { name: "Japan", code: "jp" },
netherlands: { name: "Netherlands", code: "nl" },
portugal: { name: "Portugal", code: "pt" },
england: { name: "England", code: "gb-eng" },
uruguay: { name: "Uruguay", code: "uy" },
croatia: { name: "Croatia", code: "hr" },
brazil: { name: "Brazil", code: "br" },
france: { name: "France", code: "fr" },
morocco: { name: "Morocco", code: "ma" },
belgium: { name: "Belgium", code: "be" },
italy: { name: "Italy", code: "it" },
argentina: { name: "Argentina", code: "ar" },
mexico: { name: "Mexico", code: "mx" },
germany: { name: "Germany", code: "de" },
norway: { name: "Norway", code: "no" },
costaRica: { name: "Costa Rica", code: "cr" },
serbia: { name: "Serbia", code: "rs" },
ecuador: { name: "Ecuador", code: "ec" },
ghana: { name: "Ghana", code: "gh" },
wales: { name: "Wales", code: "gb-wls" },
canada: { name: "Canada", code: "ca" },
denmark: { name: "Denmark", code: "dk" },
cameroon: { name: "Cameroon", code: "cm" },
poland: { name: "Poland", code: "pl" },
senegal: { name: "Senegal", code: "sn" },
tunisia: { name: "Tunisia", code: "tn" },
switzerland: { name: "Switzerland", code: "ch" },
peru: { name: "Peru", code: "pe" },
qatar: { name: "Qatar", code: "qa" },
sweden: { name: "Sweden", code: "se" },
austria: { name: "Austria", code: "at" },
} satisfies Record<string, Team>;
export const ROUNDS: Round[] = [
{
name: "Round of 32",
matches: [
{
id: "w-r32-1",
home: { team: TEAMS.spain, score: 3 },
away: { team: TEAMS.costaRica, score: 0 },
winner: "home",
},
{
id: "w-r32-2",
home: { team: TEAMS.japan, score: 2 },
away: { team: TEAMS.serbia, score: 1 },
winner: "home",
},
{
id: "w-r32-3",
home: { team: TEAMS.netherlands, score: 2 },
away: { team: TEAMS.ecuador, score: 0 },
winner: "home",
},
{
id: "w-r32-4",
home: { team: TEAMS.ghana, score: 2 },
away: { team: TEAMS.portugal, score: 3 },
winner: "away",
},
{
id: "w-r32-5",
home: { team: TEAMS.england, score: 4 },
away: { team: TEAMS.wales, score: 0 },
winner: "home",
},
{
id: "w-r32-6",
home: { team: TEAMS.canada, score: 0 },
away: { team: TEAMS.uruguay, score: 2 },
winner: "away",
},
{
id: "w-r32-7",
home: { team: TEAMS.croatia, score: 1 },
away: { team: TEAMS.denmark, score: 0 },
winner: "home",
},
{
id: "w-r32-8",
home: { team: TEAMS.brazil, score: 3 },
away: { team: TEAMS.cameroon, score: 1 },
winner: "home",
},
{
id: "w-r32-9",
home: { team: TEAMS.france, score: 2 },
away: { team: TEAMS.poland, score: 1 },
winner: "home",
},
{
id: "w-r32-10",
home: { team: TEAMS.senegal, score: 0 },
away: { team: TEAMS.morocco, score: 1 },
winner: "away",
},
{
id: "w-r32-11",
home: { team: TEAMS.belgium, score: 2 },
away: { team: TEAMS.tunisia, score: 0 },
winner: "home",
},
{
id: "w-r32-12",
home: { team: TEAMS.switzerland, score: 1 },
away: { team: TEAMS.italy, score: 3 },
winner: "away",
},
{
id: "w-r32-13",
home: { team: TEAMS.argentina, score: 2 },
away: { team: TEAMS.peru, score: 0 },
winner: "home",
},
{
id: "w-r32-14",
home: { team: TEAMS.qatar, score: 0 },
away: { team: TEAMS.mexico, score: 1 },
winner: "away",
},
{
id: "w-r32-15",
home: { team: TEAMS.germany, score: 4 },
away: { team: TEAMS.sweden, score: 2 },
winner: "home",
},
{
id: "w-r32-16",
home: { team: TEAMS.austria, score: 1 },
away: { team: TEAMS.norway, score: 2 },
winner: "away",
},
],
},
{
name: "Round of 16",
matches: [
{
id: "w-r16-1",
home: { team: TEAMS.spain, score: 2 },
away: { team: TEAMS.japan, score: 0 },
winner: "home",
},
{
id: "w-r16-2",
home: { team: TEAMS.netherlands, score: 1 },
away: { team: TEAMS.portugal, score: 3 },
winner: "away",
},
{
id: "w-r16-3",
home: { team: TEAMS.england, score: 2 },
away: { team: TEAMS.uruguay, score: 1 },
winner: "home",
},
{
id: "w-r16-4",
home: { team: TEAMS.croatia, score: 0 },
away: { team: TEAMS.brazil, score: 1 },
winner: "away",
},
{
id: "w-r16-5",
home: { team: TEAMS.france, score: 3 },
away: { team: TEAMS.morocco, score: 1 },
winner: "home",
},
{
id: "w-r16-6",
home: { team: TEAMS.belgium, score: 1 },
away: { team: TEAMS.italy, score: 2 },
winner: "away",
},
{
id: "w-r16-7",
home: { team: TEAMS.argentina, score: 2 },
away: { team: TEAMS.mexico, score: 0 },
winner: "home",
},
{
id: "w-r16-8",
home: { team: TEAMS.germany, score: 1, penalties: 4 },
away: { team: TEAMS.norway, score: 1, penalties: 2 },
winner: "home",
},
],
},
{
name: "Quarter-finals",
matches: [
{
id: "w-qf-1",
home: { team: TEAMS.spain, score: 1 },
away: { team: TEAMS.portugal, score: 0 },
winner: "home",
},
{
id: "w-qf-2",
home: { team: TEAMS.england, score: 2 },
away: { team: TEAMS.brazil, score: 3 },
winner: "away",
},
{
id: "w-qf-3",
home: { team: TEAMS.france, score: 2 },
away: { team: TEAMS.italy, score: 1 },
winner: "home",
},
{
id: "w-qf-4",
home: { team: TEAMS.argentina, score: 3 },
away: { team: TEAMS.germany, score: 1 },
winner: "home",
},
],
},
{
name: "Semi-finals",
matches: [
{
id: "w-sf-1",
home: { team: TEAMS.spain, score: 2 },
away: { team: TEAMS.brazil, score: 1 },
winner: "home",
},
{
id: "w-sf-2",
home: { team: TEAMS.france, score: 0, penalties: 3 },
away: { team: TEAMS.argentina, score: 0, penalties: 4 },
winner: "away",
},
],
},
{
name: "Final",
matches: [
{
id: "w-f-1",
home: { team: TEAMS.spain, score: 2 },
away: { team: TEAMS.argentina, score: 1 },
winner: "home",
},
],
},
];
"use client";
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import {
cloneElement,
isValidElement,
type PointerEvent,
type ReactElement,
type ReactNode,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { EASE_OUT } from "@/lib/ease";
import { useDismiss } from "@/lib/hooks/use-dismiss";
import { useHoverGesture } from "@/lib/hooks/use-hover-gesture";
import { useTapGesture } from "@/lib/hooks/use-tap-gesture";
import { cn } from "@/lib/utils";
type Side = "top" | "right" | "bottom" | "left";
export interface TooltipProps {
content: ReactNode;
children: ReactElement;
side?: Side;
/** Delay before showing (ms). Default 120. */
delay?: number;
className?: string;
/** Classes for the outer wrapper span. Use to fix baseline / fill parent. */
wrapperClassName?: string;
}
// Gap between trigger and tooltip, in px.
const GAP = 8;
// Centering transform for the fixed-positioned anchor point, per side.
const anchorTransform: Record<Side, string> = {
top: "translate(-50%, -100%)",
bottom: "translate(-50%, 0)",
left: "translate(-100%, -50%)",
right: "translate(0, -50%)",
};
const transformOrigin: Record<Side, string> = {
top: "center bottom",
bottom: "center top",
left: "right center",
right: "left center",
};
// Offset is in the direction *away* from the trigger — content originates near
// the trigger and rises into resting position.
const offsetFrom: Record<Side, { x?: number; y?: number }> = {
top: { y: 8 },
bottom: { y: -8 },
left: { x: 8 },
right: { x: -8 },
};
function buildVariants(side: Side): Variants {
const o = offsetFrom[side];
return {
initial: {
opacity: 0,
scale: 0.9,
filter: "blur(5px)",
x: o.x ?? 0,
y: o.y ?? 0,
},
animate: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
x: 0,
y: 0,
transition: {
type: "spring",
stiffness: 380,
damping: 30,
mass: 0.7,
opacity: { duration: 0.14, ease: EASE_OUT },
filter: { duration: 0.18, ease: EASE_OUT },
},
},
exit: {
opacity: 0,
scale: 0.94,
filter: "blur(3px)",
x: (o.x ?? 0) * 0.6,
y: (o.y ?? 0) * 0.6,
transition: { duration: 0.12, ease: EASE_OUT },
},
};
}
const REDUCED_VARIANTS: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1, transition: { duration: 0.14, ease: EASE_OUT } },
exit: { opacity: 0, transition: { duration: 0.1, ease: EASE_OUT } },
};
// Once any tooltip has just closed, neighbouring tooltips open without the
// initial delay — moving along a toolbar feels instant after the first one.
const WARM_WINDOW_MS = 300;
let lastHiddenAt = 0;
export function Tooltip({
content,
children,
side = "top",
delay = 120,
className,
wrapperClassName,
}: TooltipProps) {
const [open, setOpen] = useState(false);
const [coords, setCoords] = useState<{ top: number; left: number } | null>(
null,
);
const id = useId();
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const anchorRef = useRef<HTMLSpanElement>(null);
const hover = useHoverGesture();
const reduce = useReducedMotion();
// Anchor point in viewport coords, on the edge of the trigger facing `side`.
// Position:fixed means these viewport coords place the tooltip directly, so
// it escapes every ancestor's stacking context and overflow.
const place = useCallback(() => {
const el = anchorRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
const point: Record<Side, { top: number; left: number }> = {
top: { top: r.top - GAP, left: cx },
bottom: { top: r.bottom + GAP, left: cx },
left: { top: cy, left: r.left - GAP },
right: { top: cy, left: r.right + GAP },
};
setCoords(point[side]);
}, [side]);
const show = useCallback(() => {
if (timer.current) clearTimeout(timer.current);
const warm = Date.now() - lastHiddenAt < WARM_WINDOW_MS;
timer.current = setTimeout(
() => {
place();
setOpen(true);
},
warm ? 0 : delay,
);
}, [delay, place]);
const hide = useCallback(() => {
if (timer.current) {
clearTimeout(timer.current);
timer.current = null;
}
if (open) lastHiddenAt = Date.now();
setOpen(false);
}, [open]);
// A finger never hovers, and Safari does not focus a button on tap either, so
// the label is only reachable if the tap itself opens the tooltip. A click
// carries no pointerType, so the pointerdown that preceded it is what says
// whether this was a tap; keyboard activation arrives with no pointerdown at
// all, and focus has already shown the label there.
const tap = useTapGesture<boolean>();
const toggleOnTap = useCallback(() => {
const gesture = tap.take();
if (!gesture || gesture.pointerType === "mouse") return;
if (gesture.state) {
hide();
return;
}
if (timer.current) clearTimeout(timer.current);
place();
setOpen(true);
}, [hide, place, tap]);
// ...and closed again by the next tap that lands somewhere else. The label
// covers nothing interactive, so that tap passes through to what it hit.
useDismiss(open, hide, anchorRef);
// Keep the tooltip pinned to the trigger while it's open and the page scrolls
// or resizes (fixed coords are viewport-relative).
useEffect(() => {
if (!open) return;
const onMove = () => place();
window.addEventListener("scroll", onMove, true);
window.addEventListener("resize", onMove);
return () => {
window.removeEventListener("scroll", onMove, true);
window.removeEventListener("resize", onMove);
};
}, [open, place]);
const variants = useMemo(
() => (reduce ? REDUCED_VARIANTS : buildVariants(side)),
[reduce, side],
);
if (!isValidElement(children)) return children;
// The label describes the trigger, so it has to name the trigger itself.
// Everything else the tooltip needs is read off the anchor below instead of
// cloned on: a handler written onto the child is the child's handler as far
// as that child can tell, and a component that owns its activation —
// hard-wiring onClick and spreading the rest of its props over it, as
// ThemeToggle does — then runs the tooltip's instead of its own. Composing
// with `props.onClick` cannot save it either, because a component element's
// props hold nothing the component does internally.
const trigger = cloneElement(children as ReactElement<Record<string, unknown>>, {
"aria-describedby": id,
});
return (
<>
{/* biome-ignore lint/a11y/noStaticElementInteractions: the anchor is not a
control — it observes the trigger it wraps. Every event listed reaches
it on its own (pointerdown/click/keydown/pointercancel bubble, focus
and blur arrive as focusin/focusout, and enter/leave are derived from
pointerover/pointerout along a path the anchor is on), so the trigger
keeps every handler it came with. */}
<span
ref={anchorRef}
className={cn("relative inline-flex align-middle", wrapperClassName)}
// Pointer events, not the mouse pair: a tap fires compatibility
// mouseenter/mouseleave that carry no pointerType, which raced the tap
// path into opening and closing the same label.
onPointerEnter={(event: PointerEvent) => {
if (hover.enter(event)) show();
}}
onPointerLeave={(event: PointerEvent) => {
if (hover.leave(event)) hide();
}}
onFocus={show}
onBlur={hide}
onPointerDown={(event: PointerEvent) => tap.start(event, open)}
// A gesture the platform took away sends no click, and a key press
// starts an activation that never had a pointer behind it. Either way
// the record has to go, or the next click reads a finger that has long
// since lifted.
onPointerCancel={tap.drop}
onKeyDown={tap.drop}
onClick={toggleOnTap}
>
{trigger}
</span>
{typeof document !== "undefined"
? createPortal(
<AnimatePresence>
{open && coords ? (
<span
aria-hidden
className="pointer-events-none fixed z-[9999]"
style={{
top: coords.top,
left: coords.left,
transform: anchorTransform[side],
}}
>
<motion.span
id={id}
role="tooltip"
variants={variants}
initial="initial"
animate="animate"
exit="exit"
style={{ transformOrigin: transformOrigin[side] }}
className={cn(
"block whitespace-nowrap rounded-lg border border-border bg-background px-2.5 py-1 text-xs font-medium text-foreground shadow-lg",
className,
)}
>
{content}
</motion.span>
</span>
) : null}
</AnimatePresence>,
document.body,
)
: null}
</>
);
}
API Reference
rounds{}The whole draw, ordered widest round first — the same array the knockout bracket takes. Any single-elimination tournament fits: each round holds half the matches of the one before it (16 → 8 → 4 → 2 → 1) and `rounds[r].matches[k]` is fed by matches `2k` and `2k + 1` of the round before it. Two rounds are enough; the wheel grows a ring per round and sizes itself to the rim.
—initialRound?numberIndex of the outermost round to draw. Earlier rounds are dropped and the kept round's own teams become the rim, so `1` on a 32-team draw opens at the Round of 16. Defaults to 0 (the whole tree); clamped to the valid range.
0className?string—Knockout Bracket
knockout-bracket.tsxPages one round at a time. The leftmost round stacks at a fixed rhythm, each later round centers between its two feeder matches, and cards, elbow connectors, headers and stage height animate into every new layout. A third place play-off sits below the tree under its own rule. Round names, team artwork, dates and result chips all come from the data you pass.
- Sat, 4 JulFT
Canada0
Morocco3 - Sun, 5 JulFT
Paraguay0
France1 - Mon, 6 JulFT
USA1
Belgium4 - Mon, 6 JulFT
Portugal0
Spain1 - Mon, 6 JulFT
Brazil1
Norway2 - Mon, 6 JulFT
Mexico2
England3 - Tue, 7 JulFT (P)
Switzerland0 (4)
Colombia0 (3) - Tue, 7 JulFT
Argentina3
Egypt2
- Fri, 10 Jul, 4:00 amFT
France2
Morocco0 - Sat, 11 Jul, 3:00 amFT
Spain2
Belgium1 - TodayFT
Norway1
England2 - TodayFT
Argentina3
Switzerland1
- Wed, 15 Jul, 4:00 am
France
Spain - Thu, 16 Jul, 3:00 am
England
Argentina
Third place play-off
Sun, 19 Jul, 3:00 amTBDTBD
"use client";
import {
KnockoutBracket,
ROUNDS,
THIRD_PLACE,
} from "@/components/motion/knockout-bracket";
// `ROUNDS` is the sample World Cup draw that ships with the component. Any other
// single-elimination tournament renders the same way. Build your own `Round[]`,
// widest round first, each round holding half the matches of the one before it,
// and pass it in:
//
// const rounds: Round[] = [
// {
// name: "Quarter-finals",
// matches: [
// {
// id: "qf-1",
// date: "Sat, 14 Mar",
// home: { team: { name: "Cloud9", logo: "/logos/c9.svg" }, score: 2 },
// away: { team: { name: "T1", logo: "/logos/t1.svg" }, score: 1 },
// winner: "home",
// badge: "BO3",
// },
// // qf-2, qf-3, qf-4 …
// ],
// },
// { name: "Semi-finals", matches: [/* fed by qf 1+2 and qf 3+4 */] },
// { name: "Grand final", matches: [/* the one final */] },
// ];
//
// A team carries a `logo` URL, an ISO country `code` for a flag, or neither, in
// which case its initials stand in. `date`, `time`, `status` and `badge` are all
// optional. `thirdPlaceLabel` renames the play-off when a tournament calls it
// something else ("Bronze match").
export function KnockoutBracketPreview() {
return (
<div className="w-full py-8">
<KnockoutBracket rounds={ROUNDS} thirdPlace={THIRD_PLACE} />
</div>
);
}
"use client";
// beui.dev/components/blocks/knockout-bracket
import { ChevronLeft, ChevronRight, Shield } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import { useMemo, useState } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type Team = {
name: string;
/**
* Any square image URL — club crest, org mark, player photo. Wins over `code`.
* Drawn as-is on the card surface, so a transparent-background mark inked for
* one theme disappears in the other: ship artwork that reads on both, or pick
* the URL yourself from your theme state.
*/
logo?: string;
/** ISO 3166-1 alpha-2 code, loaded from flagcdn.com (England is gb-eng). Used when `logo` is absent. */
code?: string;
};
export type MatchSide = {
/** null renders a TBD slot with a shield icon. */
team: Team | null;
score: number | null;
/** Present on both sides to render Google-style shootout scores — 1 (3). */
penalties?: number | null;
};
export type Match = {
id: string;
/** Kick-off day, already formatted. Omit it and the card drops the date row. */
date?: string;
time?: string;
/** Omit it and a match with a `winner` counts as finished. */
status?: "finished" | "upcoming";
home: MatchSide;
away: MatchSide;
/** Decides the marker and which side dims. */
winner?: "home" | "away";
/** Replaces the derived result chip ("FT", "FT (P)") — e.g. "AET", "BO5", "Forfeit". */
badge?: string;
};
export type Round = {
/** Shown as the column header — "Round of 32", "Upper bracket final", "Last 8". */
name: string;
matches: Match[];
};
export interface KnockoutBracketProps {
/**
* The whole draw, ordered widest round first. Any single-elimination
* tournament fits: each round holds half the matches of the one before it
* (16 → 8 → 4 → 2 → 1) and `rounds[r].matches[k]` is fed by matches `2k` and
* `2k + 1` of the round before it. Two rounds are enough.
*/
rounds: Round[];
/** Round shown as the leftmost column on mount. Defaults to 1, clamped to the valid range. */
initialRound?: number;
/** Third place play-off, rendered under the bracket instead of inside it. */
thirdPlace?: Match;
/** Heading over `thirdPlace`. Defaults to "Third place play-off". */
thirdPlaceLabel?: string;
className?: string;
}
// Card geometry drives the whole computed layout — every later match sits at the
// exact vertical midpoint of its two feeders, so pairs line up with connectors.
// Keep CARD_H in sync with the card's internal spacing.
const CARD_W = 250;
const CARD_H = 124;
// Pocket (20) + stem (20) — matches the CSS `]` connector geometry.
const GAP_X = 40;
const GAP_Y = 20;
const COL_W = CARD_W + GAP_X;
const ROW = CARD_H + GAP_Y;
const VISIBLE_COLS = 3;
const CONNECTOR_POCKET = 20;
const CONNECTOR_STEM = GAP_X - CONNECTOR_POCKET;
// Tall enough for 44px chevron hit areas without clipping the focus ring.
const HEADER_H = 44;
// Breathing room baked into the computed layout so the base column isn't flush
// against the clip edge and connector nubs aren't shaved off.
const PAD_X = 8;
const PAD_Y = 12;
// Firmer than SPRING_LAYOUT so the many cards, connectors and stage height
// glide as one piece; damping just over critical (~1.05) settles with no bounce
// and no lazy overdamped tail.
const REFLOW = {
type: "spring",
stiffness: 260,
damping: 32,
mass: 0.9,
} as const;
// Opacity cross-fades a touch ahead of the position spring so columns don't
// ghost while sliding.
const REFLOW_OPACITY = {
duration: 0.28,
ease: EASE_OUT,
} as const;
const clamp = (n: number, lo: number, hi: number) =>
Math.min(hi, Math.max(lo, n));
// Column x-offset and window test — shared by the render pass and the memoized
// layout so the two can't drift. Module-level (stable identity) so the layout
// memo can call them without widening its dependency list.
const colX = (r: number, page: number) => PAD_X + (r - page) * COL_W;
const isInWindow = (r: number, page: number, visibleCols: number) =>
r >= page && r < page + visibleCols;
type Connector = {
key: string;
/** Feeder card right edge — left of the `]` pocket. */
x: number;
/** Top feeder center Y. */
y: number;
/** Distance between the two feeder centers. */
height: number;
visible: boolean;
};
// CSS `]` pocket + stem: border-y/border-r + a hairline to the child.
// Transform/opacity only — no SVG path morph, so paging stays flicker-free.
function BracketConnector({
connector,
transition,
}: {
connector: Connector;
transition: object;
}) {
const { x, y, height, visible } = connector;
const geo = visible
? transition
: {
...transition,
x: { duration: 0 },
y: { duration: 0 },
height: { duration: 0 },
};
return (
<motion.div
aria-hidden="true"
initial={false}
animate={{ x, y, height, opacity: visible ? 1 : 0 }}
transition={geo}
className="pointer-events-none absolute left-0 top-0 rounded-r-xl border-y border-r border-border"
style={{ width: CONNECTOR_POCKET, willChange: "transform" }}
>
<span
className="absolute left-full top-1/2 h-px bg-border"
style={{ width: CONNECTOR_STEM }}
/>
</motion.div>
);
}
/** A `logo` is used as given; a country `code` loads a flag from flagcdn.com. */
const crestSrc = (team: Team) =>
team.logo ?? (team.code ? `https://flagcdn.com/w80/${team.code}.png` : null);
/** Two-letter stand-in when a team has no artwork — "Real Madrid" → RM.
* Spread, not `word[0]`: an emoji or astral first character is a surrogate pair
* and indexing it renders a replacement glyph. */
const initials = (name: string) =>
name
.split(/\s+/)
.slice(0, 2)
.map((word) => [...word][0])
.join("")
.toUpperCase();
// Fixed 28px slot whatever it holds, so names and scores stay aligned down the
// column: flag, crest, initials or the TBD shield.
function TeamCrest({ team }: { team: Team | null }) {
// The failed URL, not a boolean: a corrected logo on the same match should be
// tried again rather than stay initials for the life of the card.
const [failedSrc, setFailedSrc] = useState<string | null>(null);
const resolved = team ? crestSrc(team) : null;
const src = resolved === failedSrc ? null : resolved;
return (
<span className="flex h-5 w-7 shrink-0 items-center justify-center">
{src ? (
// Plain <img> — flags come from flagcdn.com, logos from wherever you host them.
// biome-ignore lint/performance/noImgElement: remote asset, no next/image benefit
<img
src={src}
alt=""
loading="lazy"
draggable={false}
onError={() => setFailedSrc(src)}
className={cn(
"shrink-0 rounded-[4px] border border-border/40",
// Flags are 4:3 and fill the slot; a logo keeps its own shape inside it.
team?.logo
? "h-5 w-5 border-transparent object-contain"
: "h-5 w-7 object-cover",
)}
/>
) : team ? (
// text-foreground, not muted: the /10 tint lifts the disc toward the
// muted ramp, leaving 4.44:1 light and 3.55:1 dark — both under AA.
<span className="grid size-5 place-items-center rounded-full bg-foreground/10 text-[10px] font-semibold leading-none text-foreground">
{initials(team.name)}
</span>
) : (
<Shield className="size-5 fill-current text-muted-foreground/50" />
)}
</span>
);
}
function WinnerMarker() {
return (
<svg
viewBox="0 0 6 8"
aria-hidden="true"
className="h-2 w-1.5 shrink-0 fill-foreground"
>
<path d="M6 0 0 4 6 8Z" />
</svg>
);
}
function TeamRow({
side,
isWinner,
decided,
}: {
side: MatchSide;
isWinner: boolean;
decided: boolean;
}) {
const dim = decided && !isWinner;
return (
<div className="flex items-center gap-3">
<TeamCrest team={side.team} />
<span
className={cn(
"min-w-0 flex-1 truncate text-base font-medium",
dim && "text-muted-foreground",
)}
>
{side.team?.name ?? "TBD"}
</span>
{side.score != null && (
<span
className={cn(
"shrink-0 text-base font-medium tabular-nums",
dim && "text-muted-foreground",
)}
>
{side.penalties != null
? `${side.score} (${side.penalties})`
: side.score}
</span>
)}
{/* Fixed 6px marker slot keeps every score right-aligned; the winner's
triangle fills it, losers reserve it empty. */}
<span className="flex w-1.5 shrink-0 items-center">
{isWinner && <WinnerMarker />}
</span>
</div>
);
}
function sideLabel(side: MatchSide) {
const name = side.team?.name ?? "TBD";
if (side.score == null) return name;
const pen =
side.penalties != null ? ` (${side.penalties} on penalties)` : "";
return `${name} ${side.score}${pen}`;
}
/** `status` is optional, so a decided match reads as finished without it. */
const isFinished = (m: Match) =>
m.status ? m.status === "finished" : m.winner != null;
function matchLabel(roundName: string, m: Match) {
const finished = isFinished(m);
const sides = finished
? `${sideLabel(m.home)}, ${sideLabel(m.away)}`
: `${sideLabel(m.home)} versus ${sideLabel(m.away)}`;
// Same pair the card's header row shows, so a time-only match isn't announced
// without its kick-off.
const schedule = finished ? [] : [m.date, m.time].filter(Boolean);
const when = schedule.length ? `, ${schedule.join(", ")}` : "";
const winnerName = m.winner ? m[m.winner].team?.name : undefined;
const outcome = winnerName ? `, ${winnerName} won` : "";
return `${roundName}: ${sides}${when}${outcome}`;
}
function MatchCard({ match }: { match: Match }) {
const finished = isFinished(match);
const decided = finished && match.winner != null;
const shootout =
match.home.penalties != null || match.away.penalties != null;
// A per-match `badge` wins, so a draw that isn't football can label its own
// result ("AET", "BO5", "Forfeit") instead of the derived full-time chip.
const badge = match.badge ?? (finished ? (shootout ? "FT (P)" : "FT") : null);
return (
<div
style={{ width: CARD_W, height: CARD_H }}
className="rounded-2xl border border-border bg-card p-4"
>
{/* h-5 holds the row open when a match carries no date or badge, so a
dateless draw's cards don't sit top-heavy inside the fixed CARD_H. */}
<div className="mb-3 flex h-5 items-center justify-between gap-2">
<span className="min-w-0 flex-1 truncate text-sm leading-5 text-muted-foreground">
{[match.date, match.time].filter(Boolean).join(", ")}
</span>
{badge && (
<span className="shrink-0 rounded-full bg-background px-2.5 text-xs font-medium leading-5 text-muted-foreground">
{badge}
</span>
)}
</div>
<div className="space-y-2.5">
<TeamRow
side={match.home}
decided={decided}
isWinner={decided && match.winner === "home"}
/>
<TeamRow
side={match.away}
decided={decided}
isWinner={decided && match.winner === "away"}
/>
</div>
</div>
);
}
export function KnockoutBracket({
rounds,
initialRound = 1,
thirdPlace,
thirdPlaceLabel = "Third place play-off",
className,
}: KnockoutBracketProps) {
const reduce = useReducedMotion();
const visibleCols = Math.min(VISIBLE_COLS, rounds.length);
// The last page shows the final two rounds (semi-finals + final), not a full
// window — so paging continues past the QF/SF/Final view down to SF + Final.
const maxPage = Math.max(0, rounds.length - Math.min(2, rounds.length));
const [page, setPage] = useState(() => clamp(initialRound, 0, maxPage));
// Shared reflow — cards, connectors, headers and stage height page together.
// Height springs with the same token (layout morph is the product feel for
// collapsing rounds); opacity uses a short ease so fades don't lag the glide.
const transition = reduce
? { duration: 0 }
: { ...REFLOW, opacity: REFLOW_OPACITY };
const pageStatus = useMemo(() => {
const names = rounds
.slice(page, page + visibleCols)
.map((round) => round.name);
if (names.length <= 1) return `Showing ${names[0] ?? "rounds"}`;
if (names.length === 2) return `Showing ${names[0]} and ${names[1]}`;
return `Showing ${names.slice(0, -1).join(", ")}, and ${names.at(-1)}`;
}, [rounds, page, visibleCols]);
// Layout is computed, not scrolled. The leftmost visible round (`page`) is the
// base and stacks at a fixed rhythm; every later match centers on its feeders,
// and behind rounds spread out (below). Cards and connectors derive from one
// pass and page together under the shared transition.
const { cy, containerHeight, connectors } = useMemo(() => {
const centers: number[][] = new Array(rounds.length);
const base = rounds[page];
centers[page] = base.matches.map((_, i) => PAD_Y + i * ROW + CARD_H / 2);
for (let r = page + 1; r < rounds.length; r++) {
const feeders = centers[r - 1];
const row: number[] = [];
for (let k = 0; k < rounds[r].matches.length; k++) {
const top = feeders[2 * k];
if (top == null) {
// A round with more matches than its feeders allow (an odd draw, a bye
// left out) stacks a full row under the last card placed in this
// round — a fixed rhythm from the top can land on top of a midpoint.
const prev = row[k - 1];
row[k] = prev == null ? PAD_Y + CARD_H / 2 : prev + ROW;
} else {
row[k] = (top + (feeders[2 * k + 1] ?? top)) / 2;
}
}
centers[r] = row;
}
// Behind rounds keep their natural spread (spacing halves each step out,
// each match straddling its parent) instead of collapsing, so paging back
// slides a formed column in from the left just as paging forward does.
for (let r = page - 1; r >= 0; r--) {
const half = ROW / 2 ** (page - r + 1);
centers[r] = rounds[r].matches.map((_, i) => {
const parent = centers[r + 1][Math.floor(i / 2)] ?? PAD_Y;
return parent + (i % 2 === 0 ? -half : half);
});
}
const list: Connector[] = [];
for (let r = 1; r < rounds.length; r++) {
const feederRight = colX(r - 1, page) + CARD_W;
const visible =
isInWindow(r, page, visibleCols) &&
isInWindow(r - 1, page, visibleCols);
rounds[r].matches.forEach((_, k) => {
const yTop = centers[r - 1][2 * k] ?? centers[r][k];
const yBot = centers[r - 1][2 * k + 1] ?? yTop;
list.push({
key: `${r}-${k}`,
x: feederRight,
y: yTop,
height: Math.max(0, yBot - yTop),
visible,
});
});
}
// Measured, not derived from the base count: a fallback-stacked round can
// run past the base column, and the stage clips its overflow.
// Seeded with one card's center so an empty round yields a real height
// rather than -Infinity.
const lowest = Math.max(
PAD_Y + CARD_H / 2,
...centers.slice(page, page + visibleCols).flat(),
);
return {
cy: centers,
containerHeight: lowest + CARD_H / 2 + PAD_Y,
connectors: list,
};
}, [rounds, page, visibleCols]);
const containerWidth =
visibleCols * CARD_W + (visibleCols - 1) * GAP_X + 2 * PAD_X;
return (
<div
className={cn(
"w-full max-w-full overflow-x-auto overscroll-x-contain",
className,
)}
>
<section
aria-label="Tournament bracket"
className="relative mx-auto"
style={{ width: containerWidth }}
>
<div className="sr-only" aria-live="polite">
{pageStatus}
</div>
{/* Only the gliding round titles are clipped (they enter/exit at the
canvas edge); the chevron buttons sit outside that clip. */}
<div className="relative" style={{ height: HEADER_H }}>
<div className="absolute inset-0 overflow-hidden">
{rounds.map((round, r) => (
<motion.div
key={round.name}
aria-hidden={isInWindow(r, page, visibleCols) ? undefined : true}
initial={false}
animate={{
x: colX(r, page),
opacity: isInWindow(r, page, visibleCols) ? 1 : 0,
}}
transition={transition}
className="absolute left-0 top-0 flex h-full items-center justify-center text-sm font-bold text-foreground"
style={{ width: CARD_W }}
>
{round.name}
</motion.div>
))}
</div>
{page > 0 && (
<button
type="button"
onClick={() => setPage((p) => clamp(p - 1, 0, maxPage))}
aria-label="Previous round"
// Inset by PAD_X so the hover fill clears the scroll clip; 44px
// button is the tap target, the inner circle the visible affordance.
style={{ left: PAD_X }}
className="group absolute top-1/2 z-10 grid size-11 -translate-y-1/2 place-items-center rounded-full outline-none"
>
<span className="grid size-9 place-items-center rounded-full text-muted-foreground transition-colors group-hover:bg-foreground/10 group-hover:text-foreground group-focus-visible:ring-2 group-focus-visible:ring-ring">
<ChevronLeft className="size-5" />
</span>
</button>
)}
{page < maxPage && (
<button
type="button"
onClick={() => setPage((p) => clamp(p + 1, 0, maxPage))}
aria-label="Next round"
style={{ right: PAD_X }}
className="group absolute top-1/2 z-10 grid size-11 -translate-y-1/2 place-items-center rounded-full outline-none"
>
<span className="grid size-9 place-items-center rounded-full text-muted-foreground transition-colors group-hover:bg-foreground/10 group-hover:text-foreground group-focus-visible:ring-2 group-focus-visible:ring-ring">
<ChevronRight className="size-5" />
</span>
</button>
)}
</div>
{/* Stage height springs with REFLOW so the bracket collapses as one
piece with the cards — layout property is intentional here. */}
<motion.div
className="relative overflow-hidden"
initial={false}
animate={{ height: containerHeight }}
transition={transition}
style={{ width: containerWidth }}
>
{connectors.map((c) => (
<BracketConnector
key={c.key}
connector={c}
transition={transition}
/>
))}
{rounds.map((round, r) => {
const roundVisible = isInWindow(r, page, visibleCols);
return (
<ul
key={round.name}
aria-label={round.name}
aria-hidden={roundVisible ? undefined : true}
className="m-0 list-none p-0"
>
{round.matches.map((match, k) => (
<motion.li
key={match.id}
aria-label={matchLabel(round.name, match)}
initial={false}
animate={{
x: colX(r, page),
y: cy[r][k] - CARD_H / 2,
opacity: roundVisible ? 1 : 0,
}}
transition={transition}
className="absolute left-0 top-0"
style={{ willChange: "transform" }}
>
<MatchCard match={match} />
</motion.li>
))}
</ul>
);
})}
</motion.div>
{/* Outside the bracket stage — it feeds off the semi-finals rather than
into the final, so it gets its own rule instead of a column. */}
{thirdPlace && (
<div
className="mt-8 border-t border-border pt-6"
style={{ paddingLeft: PAD_X }}
>
<ul aria-label={thirdPlaceLabel} className="m-0 list-none p-0">
<li aria-label={matchLabel(thirdPlaceLabel, thirdPlace)}>
<p className="mb-2 text-sm leading-5 text-muted-foreground/70">
{thirdPlaceLabel}
</p>
<MatchCard match={thirdPlace} />
</li>
</ul>
</div>
)}
</section>
</div>
);
}
// ── Sample data ──────────────────────────────────────────────────────────────
// A full World Cup knockout stage, here to demo the shape. Swap it for your own
// tournament. Rounds run widest first and each holds half as many matches as the
// one before it (16 → 8 → 4 → 2 → 1); `matches[k]` of a round is fed by matches
// `2k` and `2k + 1` of the round before it, which is what pairs the connectors.
// Any draw works: start at the round you have (Round of 16, quarter-finals),
// give teams a `logo` instead of a country `code`, or neither for initials.
export const TEAMS = {
southAfrica: { name: "South Africa", code: "za" },
canada: { name: "Canada", code: "ca" },
netherlands: { name: "Netherlands", code: "nl" },
morocco: { name: "Morocco", code: "ma" },
germany: { name: "Germany", code: "de" },
paraguay: { name: "Paraguay", code: "py" },
france: { name: "France", code: "fr" },
sweden: { name: "Sweden", code: "se" },
belgium: { name: "Belgium", code: "be" },
senegal: { name: "Senegal", code: "sn" },
usa: { name: "USA", code: "us" },
bosnia: { name: "Bosnia and Herzegovina", code: "ba" },
spain: { name: "Spain", code: "es" },
austria: { name: "Austria", code: "at" },
portugal: { name: "Portugal", code: "pt" },
croatia: { name: "Croatia", code: "hr" },
brazil: { name: "Brazil", code: "br" },
japan: { name: "Japan", code: "jp" },
ivoryCoast: { name: "Côte d'Ivoire", code: "ci" },
norway: { name: "Norway", code: "no" },
mexico: { name: "Mexico", code: "mx" },
ecuador: { name: "Ecuador", code: "ec" },
england: { name: "England", code: "gb-eng" },
drCongo: { name: "DR Congo", code: "cd" },
switzerland: { name: "Switzerland", code: "ch" },
algeria: { name: "Algeria", code: "dz" },
colombia: { name: "Colombia", code: "co" },
ghana: { name: "Ghana", code: "gh" },
australia: { name: "Australia", code: "au" },
egypt: { name: "Egypt", code: "eg" },
argentina: { name: "Argentina", code: "ar" },
caboVerde: { name: "Cabo Verde", code: "cv" },
} satisfies Record<string, Team>;
export const ROUNDS: Round[] = [
{
name: "Round of 32",
matches: [
{
id: "r32-1",
date: "Mon, 29 Jun",
status: "finished",
home: { team: TEAMS.southAfrica, score: 0 },
away: { team: TEAMS.canada, score: 1 },
winner: "away",
},
{
id: "r32-2",
date: "Tue, 30 Jun",
status: "finished",
home: { team: TEAMS.netherlands, score: 1, penalties: 2 },
away: { team: TEAMS.morocco, score: 1, penalties: 3 },
winner: "away",
},
{
id: "r32-3",
date: "Tue, 30 Jun",
status: "finished",
home: { team: TEAMS.germany, score: 1, penalties: 3 },
away: { team: TEAMS.paraguay, score: 1, penalties: 4 },
winner: "away",
},
{
id: "r32-4",
date: "Wed, 1 Jul",
status: "finished",
home: { team: TEAMS.france, score: 3 },
away: { team: TEAMS.sweden, score: 0 },
winner: "home",
},
{
id: "r32-5",
date: "Thu, 2 Jul",
status: "finished",
home: { team: TEAMS.belgium, score: 3 },
away: { team: TEAMS.senegal, score: 2 },
winner: "home",
},
{
id: "r32-6",
date: "Thu, 2 Jul",
status: "finished",
home: { team: TEAMS.usa, score: 2 },
away: { team: TEAMS.bosnia, score: 0 },
winner: "home",
},
{
id: "r32-7",
date: "Fri, 3 Jul",
status: "finished",
home: { team: TEAMS.spain, score: 3 },
away: { team: TEAMS.austria, score: 0 },
winner: "home",
},
{
id: "r32-8",
date: "Fri, 3 Jul",
status: "finished",
home: { team: TEAMS.portugal, score: 2 },
away: { team: TEAMS.croatia, score: 1 },
winner: "home",
},
{
id: "r32-9",
date: "Mon, 29 Jun",
status: "finished",
home: { team: TEAMS.brazil, score: 2 },
away: { team: TEAMS.japan, score: 1 },
winner: "home",
},
{
id: "r32-10",
date: "Tue, 30 Jun",
status: "finished",
home: { team: TEAMS.ivoryCoast, score: 1 },
away: { team: TEAMS.norway, score: 2 },
winner: "away",
},
{
id: "r32-11",
date: "Wed, 1 Jul",
status: "finished",
home: { team: TEAMS.mexico, score: 2 },
away: { team: TEAMS.ecuador, score: 0 },
winner: "home",
},
{
id: "r32-12",
date: "Wed, 1 Jul",
status: "finished",
home: { team: TEAMS.england, score: 2 },
away: { team: TEAMS.drCongo, score: 1 },
winner: "home",
},
{
id: "r32-13",
date: "Fri, 3 Jul",
status: "finished",
home: { team: TEAMS.switzerland, score: 2 },
away: { team: TEAMS.algeria, score: 0 },
winner: "home",
},
{
id: "r32-14",
date: "Sat, 4 Jul",
status: "finished",
home: { team: TEAMS.colombia, score: 1 },
away: { team: TEAMS.ghana, score: 0 },
winner: "home",
},
{
id: "r32-15",
date: "Fri, 3 Jul",
status: "finished",
home: { team: TEAMS.australia, score: 1, penalties: 2 },
away: { team: TEAMS.egypt, score: 1, penalties: 4 },
winner: "away",
},
{
id: "r32-16",
date: "Sat, 4 Jul",
status: "finished",
home: { team: TEAMS.argentina, score: 3 },
away: { team: TEAMS.caboVerde, score: 2 },
winner: "home",
},
],
},
{
name: "Round of 16",
matches: [
{
id: "r16-1",
date: "Sat, 4 Jul",
status: "finished",
home: { team: TEAMS.canada, score: 0 },
away: { team: TEAMS.morocco, score: 3 },
winner: "away",
},
{
id: "r16-2",
date: "Sun, 5 Jul",
status: "finished",
home: { team: TEAMS.paraguay, score: 0 },
away: { team: TEAMS.france, score: 1 },
winner: "away",
},
{
id: "r16-3",
date: "Mon, 6 Jul",
status: "finished",
home: { team: TEAMS.usa, score: 1 },
away: { team: TEAMS.belgium, score: 4 },
winner: "away",
},
{
id: "r16-4",
date: "Mon, 6 Jul",
status: "finished",
home: { team: TEAMS.portugal, score: 0 },
away: { team: TEAMS.spain, score: 1 },
winner: "away",
},
{
id: "r16-5",
date: "Mon, 6 Jul",
status: "finished",
home: { team: TEAMS.brazil, score: 1 },
away: { team: TEAMS.norway, score: 2 },
winner: "away",
},
{
id: "r16-6",
date: "Mon, 6 Jul",
status: "finished",
home: { team: TEAMS.mexico, score: 2 },
away: { team: TEAMS.england, score: 3 },
winner: "away",
},
{
id: "r16-7",
date: "Tue, 7 Jul",
status: "finished",
home: { team: TEAMS.switzerland, score: 0, penalties: 4 },
away: { team: TEAMS.colombia, score: 0, penalties: 3 },
winner: "home",
},
{
id: "r16-8",
date: "Tue, 7 Jul",
status: "finished",
home: { team: TEAMS.argentina, score: 3 },
away: { team: TEAMS.egypt, score: 2 },
winner: "home",
},
],
},
{
name: "Quarter-finals",
matches: [
{
id: "qf-1",
date: "Fri, 10 Jul",
time: "4:00 am",
status: "finished",
home: { team: TEAMS.france, score: 2 },
away: { team: TEAMS.morocco, score: 0 },
winner: "home",
},
{
id: "qf-2",
date: "Sat, 11 Jul",
time: "3:00 am",
status: "finished",
home: { team: TEAMS.spain, score: 2 },
away: { team: TEAMS.belgium, score: 1 },
winner: "home",
},
{
id: "qf-3",
date: "Today",
status: "finished",
home: { team: TEAMS.norway, score: 1 },
away: { team: TEAMS.england, score: 2 },
winner: "away",
},
{
id: "qf-4",
date: "Today",
status: "finished",
home: { team: TEAMS.argentina, score: 3 },
away: { team: TEAMS.switzerland, score: 1 },
winner: "home",
},
],
},
{
name: "Semi-finals",
matches: [
{
id: "sf-1",
date: "Wed, 15 Jul",
time: "4:00 am",
status: "upcoming",
home: { team: TEAMS.france, score: null },
away: { team: TEAMS.spain, score: null },
},
{
id: "sf-2",
date: "Thu, 16 Jul",
time: "3:00 am",
status: "upcoming",
home: { team: TEAMS.england, score: null },
away: { team: TEAMS.argentina, score: null },
},
],
},
{
name: "Final",
matches: [
{
id: "f-1",
date: "Mon, 20 Jul",
time: "3:00 am",
status: "upcoming",
home: { team: null, score: null },
away: { team: null, score: null },
},
],
},
];
// Both slots stay TBD until the semi-finals resolve, same as the final.
export const THIRD_PLACE: Match = {
id: "tp-1",
date: "Sun, 19 Jul",
time: "3:00 am",
status: "upcoming",
home: { team: null, score: null },
away: { team: null, score: null },
};
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// 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;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
"use client";
import { type RefObject, useEffect } from "react";
/**
* What the dismissing gesture does to the control it landed on.
*
* `"pass-through"` is the platform norm (native popover light-dismiss): the
* tap closes the overlay *and* activates whatever was under it. Use
* `"consume"` where the open overlay sits over or beside controls that would
* be costly to trigger by accident — the dismissal then swallows the
* activation too, so the gesture only closes.
*/
export type DismissBehavior = "pass-through" | "consume";
export interface DismissOptions {
/** Default `"pass-through"`. */
behavior?: DismissBehavior;
/** Dismiss on Escape as well. Default true. */
escape?: boolean;
/** Return true for an outside target that should *not* dismiss. Must be stable. */
ignore?: (target: Element) => boolean;
}
/**
* What every currently open dismiss scope counts as inside itself. A consumed
* dismissal reads this to tell a stray gesture from one that belongs to an
* overlay in front of it: overlays have no shared z-order to consult, but the
* one the gesture landed in has said as much by registering it.
*/
const openScopes = new Set<(target: Element) => boolean>();
function claimedByAnotherScope(
self: (target: Element) => boolean,
target: Element,
) {
for (const scope of openScopes) {
if (scope !== self && scope(target)) return true;
}
return false;
}
// preventDefault on pointerdown does not suppress the click that follows, so
// consuming a gesture means swallowing that click itself. The swallower
// deliberately outlives the effect that installed it — the dismissal it
// belongs to has already unmounted or re-rendered by the time the click lands.
// It releases on that click, or on the next gesture if the pointer is dragged
// away and no click ever arrives, so it can never eat a later one. A keydown
// releases it too: a gesture that ends with neither a click nor a cancel would
// otherwise leave it armed, and the click Enter synthesizes on some focused
// control is not the one this dismissal was owed.
function consumeActivation(source: Event) {
const swallow = (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
release();
};
const restart = (event: Event) => {
if (event !== source) release();
};
const release = () => {
window.removeEventListener("click", swallow, true);
window.removeEventListener("pointerdown", restart, true);
window.removeEventListener("pointercancel", restart, true);
window.removeEventListener("keydown", release, true);
};
window.addEventListener("click", swallow, true);
window.addEventListener("pointerdown", restart, true);
window.addEventListener("pointercancel", restart, true);
window.addEventListener("keydown", release, true);
}
/**
* Close an open overlay on Escape or a pointerdown outside `ref`. Pass `null`
* for `ref` when what counts as inside isn't one element, and say so with
* `ignore` instead.
*
* The pointerdown listener is capture-phase: a bubble-phase one is blinded by
* any handler in between that stops propagation, and an overlay cannot know
* what it is layered over. `onDismiss` and `ignore` must be stable (wrap in
* useCallback) so the listeners aren't re-bound every render while open.
*/
export function useDismiss(
open: boolean,
onDismiss: () => void,
ref: RefObject<HTMLElement | null> | null,
{
behavior = "pass-through",
escape: dismissOnEscape = true,
ignore,
}: DismissOptions = {},
) {
useEffect(() => {
if (!open) return;
const inside = (target: Element) =>
Boolean(ref?.current?.contains(target)) || Boolean(ignore?.(target));
const onKey = (event: KeyboardEvent) => {
if (dismissOnEscape && event.key === "Escape") onDismiss();
};
const onPointer = (event: PointerEvent) => {
const target = event.target as Element | null;
if (!target || inside(target)) return;
// Outside this overlay, but inside one that is also open: the gesture is
// that overlay's to answer, and swallowing its click from behind would
// cost the user the control they actually aimed at.
if (behavior === "consume" && !claimedByAnotherScope(inside, target)) {
consumeActivation(event);
}
onDismiss();
};
openScopes.add(inside);
window.addEventListener("keydown", onKey);
window.addEventListener("pointerdown", onPointer, true);
return () => {
openScopes.delete(inside);
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer, true);
};
}, [open, onDismiss, ref, behavior, dismissOnEscape, ignore]);
}
"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;
}
"use client";
import { useMemo, useRef } from "react";
import { isHoveringPointer } from "@/lib/touch";
interface BoundaryEvent {
pointerId: number;
pointerType: string;
buttons: number;
}
export interface HoverGesture {
/** True when this enter starts a hover: the pointer arrived resting, not pressing. */
enter: (event: BoundaryEvent) => boolean;
/** True when this leave ends a hover that entered as one. */
leave: (event: BoundaryEvent) => boolean;
}
/**
* Pairs a surface's enter with its leave, per pointer.
*
* `isHoveringPointer` answers the question the *enter* asks — is this pointer
* resting on the surface or pressing it — and both boundary cases go wrong if
* the leave is asked the same question again:
*
* - A pen with no hover never rests. It arrives in contact, taps, and the spec
* then requires its boundary events after `pointerup`, so the leave carries
* `buttons: 0` and reads as a mouse gliding off. Hover teardown then undid
* the tap — the panel the pen had just opened closed under it.
* - A mouse pressed on the surface and dragged off leaves with `buttons: 1`.
* Skipping teardown there strands the surface open: the release happens
* outside, and no second leave ever comes.
*
* So the state a hover holds is released by the pointer that took it, whatever
* the buttons say at the boundary, and a pointer that arrived in contact never
* took it in the first place. Contact is the exception tracked here, not
* hover: a leave from a pointer this surface never saw enter — mounted under
* the cursor, say — still counts, since the alternative is state with no way
* out.
*/
export function useHoverGesture(): HoverGesture {
const contact = useRef(new Set<number>());
return useMemo(
() => ({
enter: (event) => {
if (isHoveringPointer(event)) {
contact.current.delete(event.pointerId);
return true;
}
contact.current.add(event.pointerId);
return false;
},
leave: (event) => {
const arrivedInContact = contact.current.delete(event.pointerId);
return !arrivedInContact && event.pointerType !== "touch";
},
}),
[],
);
}
"use client";
import { useMemo, useRef } from "react";
/** What a pointerdown recorded, read back by the click that ends its gesture. */
export interface TapRecord<S> {
/** Which input started the gesture. */
pointerType: string;
/** What the surface was showing when it started. */
state: S;
}
export interface TapGesture<S> {
/** Record the gesture a pointerdown starts, with the state it starts in. */
start: (event: { pointerType: string }, state: S) => void;
/** Read the record and clear it. `null` when no pointer is behind this click. */
take: () => TapRecord<S> | null;
/** Drop the record: this gesture will never spend it on a click. */
drop: () => void;
}
/**
* The pointer gesture behind a click, recorded where the click cannot report
* it. A `click` carries no `pointerType` in the engines that matter, so the
* `pointerdown` before it is the only thing that says which input activated
* the control — and whether one did at all, since keyboard activation
* synthesizes a click with no pointer behind it.
*
* State goes in with the record because a click reports that no better: a
* browser that focuses a control on contact can open the very panel the tap
* was meant to open, and reading "is it open" at click time then undoes it.
* What the gesture started against is what it acts on.
*
* The record is spent by one click and dropped by everything else, because a
* record that outlives its gesture is worse than none:
*
* - A scroll or an OS gesture takes the touch away — `pointercancel`, no click
* ever — and the finger would sit in the record until some later click.
* - That later click is often `Enter` on a keyboard, which arrives with no
* pointerdown of its own and would inherit the abandoned finger. A keydown
* is the start of a keyboard activation and never part of a tap, so it drops
* the record too.
*
* Both ends have to be wired by the surface: `drop` on `onPointerCancel` and
* on `onKeyDown`.
*/
export function useTapGesture<S>(): TapGesture<S> {
const record = useRef<TapRecord<S> | null>(null);
return useMemo(
() => ({
start: (event, state) => {
record.current = { pointerType: event.pointerType, state };
},
take: () => {
const spent = record.current;
record.current = null;
return spent;
},
drop: () => {
record.current = null;
},
}),
[],
);
}
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
Copy the source code
"use client";
// beui.dev/components/blocks/knockout-bracket
import { ChevronLeft, ChevronRight, Shield } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import { useMemo, useState } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type Team = {
name: string;
/**
* Any square image URL — club crest, org mark, player photo. Wins over `code`.
* Drawn as-is on the card surface, so a transparent-background mark inked for
* one theme disappears in the other: ship artwork that reads on both, or pick
* the URL yourself from your theme state.
*/
logo?: string;
/** ISO 3166-1 alpha-2 code, loaded from flagcdn.com (England is gb-eng). Used when `logo` is absent. */
code?: string;
};
export type MatchSide = {
/** null renders a TBD slot with a shield icon. */
team: Team | null;
score: number | null;
/** Present on both sides to render Google-style shootout scores — 1 (3). */
penalties?: number | null;
};
export type Match = {
id: string;
/** Kick-off day, already formatted. Omit it and the card drops the date row. */
date?: string;
time?: string;
/** Omit it and a match with a `winner` counts as finished. */
status?: "finished" | "upcoming";
home: MatchSide;
away: MatchSide;
/** Decides the marker and which side dims. */
winner?: "home" | "away";
/** Replaces the derived result chip ("FT", "FT (P)") — e.g. "AET", "BO5", "Forfeit". */
badge?: string;
};
export type Round = {
/** Shown as the column header — "Round of 32", "Upper bracket final", "Last 8". */
name: string;
matches: Match[];
};
export interface KnockoutBracketProps {
/**
* The whole draw, ordered widest round first. Any single-elimination
* tournament fits: each round holds half the matches of the one before it
* (16 → 8 → 4 → 2 → 1) and `rounds[r].matches[k]` is fed by matches `2k` and
* `2k + 1` of the round before it. Two rounds are enough.
*/
rounds: Round[];
/** Round shown as the leftmost column on mount. Defaults to 1, clamped to the valid range. */
initialRound?: number;
/** Third place play-off, rendered under the bracket instead of inside it. */
thirdPlace?: Match;
/** Heading over `thirdPlace`. Defaults to "Third place play-off". */
thirdPlaceLabel?: string;
className?: string;
}
// Card geometry drives the whole computed layout — every later match sits at the
// exact vertical midpoint of its two feeders, so pairs line up with connectors.
// Keep CARD_H in sync with the card's internal spacing.
const CARD_W = 250;
const CARD_H = 124;
// Pocket (20) + stem (20) — matches the CSS `]` connector geometry.
const GAP_X = 40;
const GAP_Y = 20;
const COL_W = CARD_W + GAP_X;
const ROW = CARD_H + GAP_Y;
const VISIBLE_COLS = 3;
const CONNECTOR_POCKET = 20;
const CONNECTOR_STEM = GAP_X - CONNECTOR_POCKET;
// Tall enough for 44px chevron hit areas without clipping the focus ring.
const HEADER_H = 44;
// Breathing room baked into the computed layout so the base column isn't flush
// against the clip edge and connector nubs aren't shaved off.
const PAD_X = 8;
const PAD_Y = 12;
// Firmer than SPRING_LAYOUT so the many cards, connectors and stage height
// glide as one piece; damping just over critical (~1.05) settles with no bounce
// and no lazy overdamped tail.
const REFLOW = {
type: "spring",
stiffness: 260,
damping: 32,
mass: 0.9,
} as const;
// Opacity cross-fades a touch ahead of the position spring so columns don't
// ghost while sliding.
const REFLOW_OPACITY = {
duration: 0.28,
ease: EASE_OUT,
} as const;
const clamp = (n: number, lo: number, hi: number) =>
Math.min(hi, Math.max(lo, n));
// Column x-offset and window test — shared by the render pass and the memoized
// layout so the two can't drift. Module-level (stable identity) so the layout
// memo can call them without widening its dependency list.
const colX = (r: number, page: number) => PAD_X + (r - page) * COL_W;
const isInWindow = (r: number, page: number, visibleCols: number) =>
r >= page && r < page + visibleCols;
type Connector = {
key: string;
/** Feeder card right edge — left of the `]` pocket. */
x: number;
/** Top feeder center Y. */
y: number;
/** Distance between the two feeder centers. */
height: number;
visible: boolean;
};
// CSS `]` pocket + stem: border-y/border-r + a hairline to the child.
// Transform/opacity only — no SVG path morph, so paging stays flicker-free.
function BracketConnector({
connector,
transition,
}: {
connector: Connector;
transition: object;
}) {
const { x, y, height, visible } = connector;
const geo = visible
? transition
: {
...transition,
x: { duration: 0 },
y: { duration: 0 },
height: { duration: 0 },
};
return (
<motion.div
aria-hidden="true"
initial={false}
animate={{ x, y, height, opacity: visible ? 1 : 0 }}
transition={geo}
className="pointer-events-none absolute left-0 top-0 rounded-r-xl border-y border-r border-border"
style={{ width: CONNECTOR_POCKET, willChange: "transform" }}
>
<span
className="absolute left-full top-1/2 h-px bg-border"
style={{ width: CONNECTOR_STEM }}
/>
</motion.div>
);
}
/** A `logo` is used as given; a country `code` loads a flag from flagcdn.com. */
const crestSrc = (team: Team) =>
team.logo ?? (team.code ? `https://flagcdn.com/w80/${team.code}.png` : null);
/** Two-letter stand-in when a team has no artwork — "Real Madrid" → RM.
* Spread, not `word[0]`: an emoji or astral first character is a surrogate pair
* and indexing it renders a replacement glyph. */
const initials = (name: string) =>
name
.split(/\s+/)
.slice(0, 2)
.map((word) => [...word][0])
.join("")
.toUpperCase();
// Fixed 28px slot whatever it holds, so names and scores stay aligned down the
// column: flag, crest, initials or the TBD shield.
function TeamCrest({ team }: { team: Team | null }) {
// The failed URL, not a boolean: a corrected logo on the same match should be
// tried again rather than stay initials for the life of the card.
const [failedSrc, setFailedSrc] = useState<string | null>(null);
const resolved = team ? crestSrc(team) : null;
const src = resolved === failedSrc ? null : resolved;
return (
<span className="flex h-5 w-7 shrink-0 items-center justify-center">
{src ? (
// Plain <img> — flags come from flagcdn.com, logos from wherever you host them.
// biome-ignore lint/performance/noImgElement: remote asset, no next/image benefit
<img
src={src}
alt=""
loading="lazy"
draggable={false}
onError={() => setFailedSrc(src)}
className={cn(
"shrink-0 rounded-[4px] border border-border/40",
// Flags are 4:3 and fill the slot; a logo keeps its own shape inside it.
team?.logo
? "h-5 w-5 border-transparent object-contain"
: "h-5 w-7 object-cover",
)}
/>
) : team ? (
// text-foreground, not muted: the /10 tint lifts the disc toward the
// muted ramp, leaving 4.44:1 light and 3.55:1 dark — both under AA.
<span className="grid size-5 place-items-center rounded-full bg-foreground/10 text-[10px] font-semibold leading-none text-foreground">
{initials(team.name)}
</span>
) : (
<Shield className="size-5 fill-current text-muted-foreground/50" />
)}
</span>
);
}
function WinnerMarker() {
return (
<svg
viewBox="0 0 6 8"
aria-hidden="true"
className="h-2 w-1.5 shrink-0 fill-foreground"
>
<path d="M6 0 0 4 6 8Z" />
</svg>
);
}
function TeamRow({
side,
isWinner,
decided,
}: {
side: MatchSide;
isWinner: boolean;
decided: boolean;
}) {
const dim = decided && !isWinner;
return (
<div className="flex items-center gap-3">
<TeamCrest team={side.team} />
<span
className={cn(
"min-w-0 flex-1 truncate text-base font-medium",
dim && "text-muted-foreground",
)}
>
{side.team?.name ?? "TBD"}
</span>
{side.score != null && (
<span
className={cn(
"shrink-0 text-base font-medium tabular-nums",
dim && "text-muted-foreground",
)}
>
{side.penalties != null
? `${side.score} (${side.penalties})`
: side.score}
</span>
)}
{/* Fixed 6px marker slot keeps every score right-aligned; the winner's
triangle fills it, losers reserve it empty. */}
<span className="flex w-1.5 shrink-0 items-center">
{isWinner && <WinnerMarker />}
</span>
</div>
);
}
function sideLabel(side: MatchSide) {
const name = side.team?.name ?? "TBD";
if (side.score == null) return name;
const pen =
side.penalties != null ? ` (${side.penalties} on penalties)` : "";
return `${name} ${side.score}${pen}`;
}
/** `status` is optional, so a decided match reads as finished without it. */
const isFinished = (m: Match) =>
m.status ? m.status === "finished" : m.winner != null;
function matchLabel(roundName: string, m: Match) {
const finished = isFinished(m);
const sides = finished
? `${sideLabel(m.home)}, ${sideLabel(m.away)}`
: `${sideLabel(m.home)} versus ${sideLabel(m.away)}`;
// Same pair the card's header row shows, so a time-only match isn't announced
// without its kick-off.
const schedule = finished ? [] : [m.date, m.time].filter(Boolean);
const when = schedule.length ? `, ${schedule.join(", ")}` : "";
const winnerName = m.winner ? m[m.winner].team?.name : undefined;
const outcome = winnerName ? `, ${winnerName} won` : "";
return `${roundName}: ${sides}${when}${outcome}`;
}
function MatchCard({ match }: { match: Match }) {
const finished = isFinished(match);
const decided = finished && match.winner != null;
const shootout =
match.home.penalties != null || match.away.penalties != null;
// A per-match `badge` wins, so a draw that isn't football can label its own
// result ("AET", "BO5", "Forfeit") instead of the derived full-time chip.
const badge = match.badge ?? (finished ? (shootout ? "FT (P)" : "FT") : null);
return (
<div
style={{ width: CARD_W, height: CARD_H }}
className="rounded-2xl border border-border bg-card p-4"
>
{/* h-5 holds the row open when a match carries no date or badge, so a
dateless draw's cards don't sit top-heavy inside the fixed CARD_H. */}
<div className="mb-3 flex h-5 items-center justify-between gap-2">
<span className="min-w-0 flex-1 truncate text-sm leading-5 text-muted-foreground">
{[match.date, match.time].filter(Boolean).join(", ")}
</span>
{badge && (
<span className="shrink-0 rounded-full bg-background px-2.5 text-xs font-medium leading-5 text-muted-foreground">
{badge}
</span>
)}
</div>
<div className="space-y-2.5">
<TeamRow
side={match.home}
decided={decided}
isWinner={decided && match.winner === "home"}
/>
<TeamRow
side={match.away}
decided={decided}
isWinner={decided && match.winner === "away"}
/>
</div>
</div>
);
}
export function KnockoutBracket({
rounds,
initialRound = 1,
thirdPlace,
thirdPlaceLabel = "Third place play-off",
className,
}: KnockoutBracketProps) {
const reduce = useReducedMotion();
const visibleCols = Math.min(VISIBLE_COLS, rounds.length);
// The last page shows the final two rounds (semi-finals + final), not a full
// window — so paging continues past the QF/SF/Final view down to SF + Final.
const maxPage = Math.max(0, rounds.length - Math.min(2, rounds.length));
const [page, setPage] = useState(() => clamp(initialRound, 0, maxPage));
// Shared reflow — cards, connectors, headers and stage height page together.
// Height springs with the same token (layout morph is the product feel for
// collapsing rounds); opacity uses a short ease so fades don't lag the glide.
const transition = reduce
? { duration: 0 }
: { ...REFLOW, opacity: REFLOW_OPACITY };
const pageStatus = useMemo(() => {
const names = rounds
.slice(page, page + visibleCols)
.map((round) => round.name);
if (names.length <= 1) return `Showing ${names[0] ?? "rounds"}`;
if (names.length === 2) return `Showing ${names[0]} and ${names[1]}`;
return `Showing ${names.slice(0, -1).join(", ")}, and ${names.at(-1)}`;
}, [rounds, page, visibleCols]);
// Layout is computed, not scrolled. The leftmost visible round (`page`) is the
// base and stacks at a fixed rhythm; every later match centers on its feeders,
// and behind rounds spread out (below). Cards and connectors derive from one
// pass and page together under the shared transition.
const { cy, containerHeight, connectors } = useMemo(() => {
const centers: number[][] = new Array(rounds.length);
const base = rounds[page];
centers[page] = base.matches.map((_, i) => PAD_Y + i * ROW + CARD_H / 2);
for (let r = page + 1; r < rounds.length; r++) {
const feeders = centers[r - 1];
const row: number[] = [];
for (let k = 0; k < rounds[r].matches.length; k++) {
const top = feeders[2 * k];
if (top == null) {
// A round with more matches than its feeders allow (an odd draw, a bye
// left out) stacks a full row under the last card placed in this
// round — a fixed rhythm from the top can land on top of a midpoint.
const prev = row[k - 1];
row[k] = prev == null ? PAD_Y + CARD_H / 2 : prev + ROW;
} else {
row[k] = (top + (feeders[2 * k + 1] ?? top)) / 2;
}
}
centers[r] = row;
}
// Behind rounds keep their natural spread (spacing halves each step out,
// each match straddling its parent) instead of collapsing, so paging back
// slides a formed column in from the left just as paging forward does.
for (let r = page - 1; r >= 0; r--) {
const half = ROW / 2 ** (page - r + 1);
centers[r] = rounds[r].matches.map((_, i) => {
const parent = centers[r + 1][Math.floor(i / 2)] ?? PAD_Y;
return parent + (i % 2 === 0 ? -half : half);
});
}
const list: Connector[] = [];
for (let r = 1; r < rounds.length; r++) {
const feederRight = colX(r - 1, page) + CARD_W;
const visible =
isInWindow(r, page, visibleCols) &&
isInWindow(r - 1, page, visibleCols);
rounds[r].matches.forEach((_, k) => {
const yTop = centers[r - 1][2 * k] ?? centers[r][k];
const yBot = centers[r - 1][2 * k + 1] ?? yTop;
list.push({
key: `${r}-${k}`,
x: feederRight,
y: yTop,
height: Math.max(0, yBot - yTop),
visible,
});
});
}
// Measured, not derived from the base count: a fallback-stacked round can
// run past the base column, and the stage clips its overflow.
// Seeded with one card's center so an empty round yields a real height
// rather than -Infinity.
const lowest = Math.max(
PAD_Y + CARD_H / 2,
...centers.slice(page, page + visibleCols).flat(),
);
return {
cy: centers,
containerHeight: lowest + CARD_H / 2 + PAD_Y,
connectors: list,
};
}, [rounds, page, visibleCols]);
const containerWidth =
visibleCols * CARD_W + (visibleCols - 1) * GAP_X + 2 * PAD_X;
return (
<div
className={cn(
"w-full max-w-full overflow-x-auto overscroll-x-contain",
className,
)}
>
<section
aria-label="Tournament bracket"
className="relative mx-auto"
style={{ width: containerWidth }}
>
<div className="sr-only" aria-live="polite">
{pageStatus}
</div>
{/* Only the gliding round titles are clipped (they enter/exit at the
canvas edge); the chevron buttons sit outside that clip. */}
<div className="relative" style={{ height: HEADER_H }}>
<div className="absolute inset-0 overflow-hidden">
{rounds.map((round, r) => (
<motion.div
key={round.name}
aria-hidden={isInWindow(r, page, visibleCols) ? undefined : true}
initial={false}
animate={{
x: colX(r, page),
opacity: isInWindow(r, page, visibleCols) ? 1 : 0,
}}
transition={transition}
className="absolute left-0 top-0 flex h-full items-center justify-center text-sm font-bold text-foreground"
style={{ width: CARD_W }}
>
{round.name}
</motion.div>
))}
</div>
{page > 0 && (
<button
type="button"
onClick={() => setPage((p) => clamp(p - 1, 0, maxPage))}
aria-label="Previous round"
// Inset by PAD_X so the hover fill clears the scroll clip; 44px
// button is the tap target, the inner circle the visible affordance.
style={{ left: PAD_X }}
className="group absolute top-1/2 z-10 grid size-11 -translate-y-1/2 place-items-center rounded-full outline-none"
>
<span className="grid size-9 place-items-center rounded-full text-muted-foreground transition-colors group-hover:bg-foreground/10 group-hover:text-foreground group-focus-visible:ring-2 group-focus-visible:ring-ring">
<ChevronLeft className="size-5" />
</span>
</button>
)}
{page < maxPage && (
<button
type="button"
onClick={() => setPage((p) => clamp(p + 1, 0, maxPage))}
aria-label="Next round"
style={{ right: PAD_X }}
className="group absolute top-1/2 z-10 grid size-11 -translate-y-1/2 place-items-center rounded-full outline-none"
>
<span className="grid size-9 place-items-center rounded-full text-muted-foreground transition-colors group-hover:bg-foreground/10 group-hover:text-foreground group-focus-visible:ring-2 group-focus-visible:ring-ring">
<ChevronRight className="size-5" />
</span>
</button>
)}
</div>
{/* Stage height springs with REFLOW so the bracket collapses as one
piece with the cards — layout property is intentional here. */}
<motion.div
className="relative overflow-hidden"
initial={false}
animate={{ height: containerHeight }}
transition={transition}
style={{ width: containerWidth }}
>
{connectors.map((c) => (
<BracketConnector
key={c.key}
connector={c}
transition={transition}
/>
))}
{rounds.map((round, r) => {
const roundVisible = isInWindow(r, page, visibleCols);
return (
<ul
key={round.name}
aria-label={round.name}
aria-hidden={roundVisible ? undefined : true}
className="m-0 list-none p-0"
>
{round.matches.map((match, k) => (
<motion.li
key={match.id}
aria-label={matchLabel(round.name, match)}
initial={false}
animate={{
x: colX(r, page),
y: cy[r][k] - CARD_H / 2,
opacity: roundVisible ? 1 : 0,
}}
transition={transition}
className="absolute left-0 top-0"
style={{ willChange: "transform" }}
>
<MatchCard match={match} />
</motion.li>
))}
</ul>
);
})}
</motion.div>
{/* Outside the bracket stage — it feeds off the semi-finals rather than
into the final, so it gets its own rule instead of a column. */}
{thirdPlace && (
<div
className="mt-8 border-t border-border pt-6"
style={{ paddingLeft: PAD_X }}
>
<ul aria-label={thirdPlaceLabel} className="m-0 list-none p-0">
<li aria-label={matchLabel(thirdPlaceLabel, thirdPlace)}>
<p className="mb-2 text-sm leading-5 text-muted-foreground/70">
{thirdPlaceLabel}
</p>
<MatchCard match={thirdPlace} />
</li>
</ul>
</div>
)}
</section>
</div>
);
}
// ── Sample data ──────────────────────────────────────────────────────────────
// A full World Cup knockout stage, here to demo the shape. Swap it for your own
// tournament. Rounds run widest first and each holds half as many matches as the
// one before it (16 → 8 → 4 → 2 → 1); `matches[k]` of a round is fed by matches
// `2k` and `2k + 1` of the round before it, which is what pairs the connectors.
// Any draw works: start at the round you have (Round of 16, quarter-finals),
// give teams a `logo` instead of a country `code`, or neither for initials.
export const TEAMS = {
southAfrica: { name: "South Africa", code: "za" },
canada: { name: "Canada", code: "ca" },
netherlands: { name: "Netherlands", code: "nl" },
morocco: { name: "Morocco", code: "ma" },
germany: { name: "Germany", code: "de" },
paraguay: { name: "Paraguay", code: "py" },
france: { name: "France", code: "fr" },
sweden: { name: "Sweden", code: "se" },
belgium: { name: "Belgium", code: "be" },
senegal: { name: "Senegal", code: "sn" },
usa: { name: "USA", code: "us" },
bosnia: { name: "Bosnia and Herzegovina", code: "ba" },
spain: { name: "Spain", code: "es" },
austria: { name: "Austria", code: "at" },
portugal: { name: "Portugal", code: "pt" },
croatia: { name: "Croatia", code: "hr" },
brazil: { name: "Brazil", code: "br" },
japan: { name: "Japan", code: "jp" },
ivoryCoast: { name: "Côte d'Ivoire", code: "ci" },
norway: { name: "Norway", code: "no" },
mexico: { name: "Mexico", code: "mx" },
ecuador: { name: "Ecuador", code: "ec" },
england: { name: "England", code: "gb-eng" },
drCongo: { name: "DR Congo", code: "cd" },
switzerland: { name: "Switzerland", code: "ch" },
algeria: { name: "Algeria", code: "dz" },
colombia: { name: "Colombia", code: "co" },
ghana: { name: "Ghana", code: "gh" },
australia: { name: "Australia", code: "au" },
egypt: { name: "Egypt", code: "eg" },
argentina: { name: "Argentina", code: "ar" },
caboVerde: { name: "Cabo Verde", code: "cv" },
} satisfies Record<string, Team>;
export const ROUNDS: Round[] = [
{
name: "Round of 32",
matches: [
{
id: "r32-1",
date: "Mon, 29 Jun",
status: "finished",
home: { team: TEAMS.southAfrica, score: 0 },
away: { team: TEAMS.canada, score: 1 },
winner: "away",
},
{
id: "r32-2",
date: "Tue, 30 Jun",
status: "finished",
home: { team: TEAMS.netherlands, score: 1, penalties: 2 },
away: { team: TEAMS.morocco, score: 1, penalties: 3 },
winner: "away",
},
{
id: "r32-3",
date: "Tue, 30 Jun",
status: "finished",
home: { team: TEAMS.germany, score: 1, penalties: 3 },
away: { team: TEAMS.paraguay, score: 1, penalties: 4 },
winner: "away",
},
{
id: "r32-4",
date: "Wed, 1 Jul",
status: "finished",
home: { team: TEAMS.france, score: 3 },
away: { team: TEAMS.sweden, score: 0 },
winner: "home",
},
{
id: "r32-5",
date: "Thu, 2 Jul",
status: "finished",
home: { team: TEAMS.belgium, score: 3 },
away: { team: TEAMS.senegal, score: 2 },
winner: "home",
},
{
id: "r32-6",
date: "Thu, 2 Jul",
status: "finished",
home: { team: TEAMS.usa, score: 2 },
away: { team: TEAMS.bosnia, score: 0 },
winner: "home",
},
{
id: "r32-7",
date: "Fri, 3 Jul",
status: "finished",
home: { team: TEAMS.spain, score: 3 },
away: { team: TEAMS.austria, score: 0 },
winner: "home",
},
{
id: "r32-8",
date: "Fri, 3 Jul",
status: "finished",
home: { team: TEAMS.portugal, score: 2 },
away: { team: TEAMS.croatia, score: 1 },
winner: "home",
},
{
id: "r32-9",
date: "Mon, 29 Jun",
status: "finished",
home: { team: TEAMS.brazil, score: 2 },
away: { team: TEAMS.japan, score: 1 },
winner: "home",
},
{
id: "r32-10",
date: "Tue, 30 Jun",
status: "finished",
home: { team: TEAMS.ivoryCoast, score: 1 },
away: { team: TEAMS.norway, score: 2 },
winner: "away",
},
{
id: "r32-11",
date: "Wed, 1 Jul",
status: "finished",
home: { team: TEAMS.mexico, score: 2 },
away: { team: TEAMS.ecuador, score: 0 },
winner: "home",
},
{
id: "r32-12",
date: "Wed, 1 Jul",
status: "finished",
home: { team: TEAMS.england, score: 2 },
away: { team: TEAMS.drCongo, score: 1 },
winner: "home",
},
{
id: "r32-13",
date: "Fri, 3 Jul",
status: "finished",
home: { team: TEAMS.switzerland, score: 2 },
away: { team: TEAMS.algeria, score: 0 },
winner: "home",
},
{
id: "r32-14",
date: "Sat, 4 Jul",
status: "finished",
home: { team: TEAMS.colombia, score: 1 },
away: { team: TEAMS.ghana, score: 0 },
winner: "home",
},
{
id: "r32-15",
date: "Fri, 3 Jul",
status: "finished",
home: { team: TEAMS.australia, score: 1, penalties: 2 },
away: { team: TEAMS.egypt, score: 1, penalties: 4 },
winner: "away",
},
{
id: "r32-16",
date: "Sat, 4 Jul",
status: "finished",
home: { team: TEAMS.argentina, score: 3 },
away: { team: TEAMS.caboVerde, score: 2 },
winner: "home",
},
],
},
{
name: "Round of 16",
matches: [
{
id: "r16-1",
date: "Sat, 4 Jul",
status: "finished",
home: { team: TEAMS.canada, score: 0 },
away: { team: TEAMS.morocco, score: 3 },
winner: "away",
},
{
id: "r16-2",
date: "Sun, 5 Jul",
status: "finished",
home: { team: TEAMS.paraguay, score: 0 },
away: { team: TEAMS.france, score: 1 },
winner: "away",
},
{
id: "r16-3",
date: "Mon, 6 Jul",
status: "finished",
home: { team: TEAMS.usa, score: 1 },
away: { team: TEAMS.belgium, score: 4 },
winner: "away",
},
{
id: "r16-4",
date: "Mon, 6 Jul",
status: "finished",
home: { team: TEAMS.portugal, score: 0 },
away: { team: TEAMS.spain, score: 1 },
winner: "away",
},
{
id: "r16-5",
date: "Mon, 6 Jul",
status: "finished",
home: { team: TEAMS.brazil, score: 1 },
away: { team: TEAMS.norway, score: 2 },
winner: "away",
},
{
id: "r16-6",
date: "Mon, 6 Jul",
status: "finished",
home: { team: TEAMS.mexico, score: 2 },
away: { team: TEAMS.england, score: 3 },
winner: "away",
},
{
id: "r16-7",
date: "Tue, 7 Jul",
status: "finished",
home: { team: TEAMS.switzerland, score: 0, penalties: 4 },
away: { team: TEAMS.colombia, score: 0, penalties: 3 },
winner: "home",
},
{
id: "r16-8",
date: "Tue, 7 Jul",
status: "finished",
home: { team: TEAMS.argentina, score: 3 },
away: { team: TEAMS.egypt, score: 2 },
winner: "home",
},
],
},
{
name: "Quarter-finals",
matches: [
{
id: "qf-1",
date: "Fri, 10 Jul",
time: "4:00 am",
status: "finished",
home: { team: TEAMS.france, score: 2 },
away: { team: TEAMS.morocco, score: 0 },
winner: "home",
},
{
id: "qf-2",
date: "Sat, 11 Jul",
time: "3:00 am",
status: "finished",
home: { team: TEAMS.spain, score: 2 },
away: { team: TEAMS.belgium, score: 1 },
winner: "home",
},
{
id: "qf-3",
date: "Today",
status: "finished",
home: { team: TEAMS.norway, score: 1 },
away: { team: TEAMS.england, score: 2 },
winner: "away",
},
{
id: "qf-4",
date: "Today",
status: "finished",
home: { team: TEAMS.argentina, score: 3 },
away: { team: TEAMS.switzerland, score: 1 },
winner: "home",
},
],
},
{
name: "Semi-finals",
matches: [
{
id: "sf-1",
date: "Wed, 15 Jul",
time: "4:00 am",
status: "upcoming",
home: { team: TEAMS.france, score: null },
away: { team: TEAMS.spain, score: null },
},
{
id: "sf-2",
date: "Thu, 16 Jul",
time: "3:00 am",
status: "upcoming",
home: { team: TEAMS.england, score: null },
away: { team: TEAMS.argentina, score: null },
},
],
},
{
name: "Final",
matches: [
{
id: "f-1",
date: "Mon, 20 Jul",
time: "3:00 am",
status: "upcoming",
home: { team: null, score: null },
away: { team: null, score: null },
},
],
},
];
// Both slots stay TBD until the semi-finals resolve, same as the final.
export const THIRD_PLACE: Match = {
id: "tp-1",
date: "Sun, 19 Jul",
time: "3:00 am",
status: "upcoming",
home: { team: null, score: null },
away: { team: null, score: null },
};
"use client";
// beui.dev/components/blocks/knockout-bracket
import { Shield } from "lucide-react";
import { motion, useInView, useReducedMotion } from "motion/react";
import {
type KeyboardEvent,
memo,
useCallback,
useId,
useMemo,
useRef,
useState,
} from "react";
import { Tooltip } from "@/components/motion/tooltip";
import { SPRING_PANEL } from "@/lib/ease";
import { useDismiss } from "@/lib/hooks/use-dismiss";
import { useHoverGesture } from "@/lib/hooks/use-hover-gesture";
import { useTapGesture } from "@/lib/hooks/use-tap-gesture";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export type Team = {
name: string;
/**
* Any square image URL — club crest, org mark, player photo. Wins over `code`.
* Drawn as-is on the node's card-colored disc, so a transparent-background
* mark inked for one theme disappears in the other: ship artwork that reads on
* both, or pick the URL yourself from your theme state.
*/
logo?: string;
/** ISO 3166-1 alpha-2 code, loaded from flagcdn.com (England is gb-eng). Used when `logo` is absent. */
code?: string;
};
export type MatchSide = {
team: Team | null;
score: number | null;
/** Present on both sides to render shootout scores — 1 (3). */
penalties?: number | null;
};
/** Structurally compatible with the knockout bracket's Match, minus the fields
* the wheel never draws (date, time, status). */
export type Match = {
id: string;
home: MatchSide;
away: MatchSide;
winner?: "home" | "away";
};
export type Round = {
/** Read out with the match in tooltips and the screen-reader list. */
name: string;
matches: Match[];
};
export interface KnockoutWheelProps {
/**
* The whole draw, ordered widest round first — the same array the knockout
* bracket takes. Any single-elimination tournament fits: each round holds half
* the matches of the one before it (16 → 8 → 4 → 2 → 1) and `rounds[r].matches[k]`
* is fed by matches `2k` and `2k + 1` of the round before it. Two rounds are
* enough; the wheel grows a ring per round and sizes itself to the rim.
*/
rounds: Round[];
/**
* Index of the outermost round to draw. Earlier rounds are dropped and the
* kept round's own teams become the rim, so `1` on a 32-team draw opens at the
* Round of 16. Defaults to 0 (the whole tree); clamped to the valid range.
*/
initialRound?: number;
className?: string;
}
const SIZE = 760;
const CENTER = SIZE / 2;
// Solid trophy glyph drawn in a 24-unit box.
const TROPHY_SIZE = 24;
const TROPHY_PATH =
"M5 1h12v3h3a2 2 0 0 1 2 2c0 3.3-2.2 5.6-5.2 6A6 6 0 0 1 12 15a6 6 0 0 1-4.8-2.9C4.2 11.6 2 9.3 2 6a2 2 0 0 1 2-2h1V1Zm0 5H4c0 1.9 1.1 3.2 2.6 3.7A12 12 0 0 1 5 6Zm14 0h-1a12 12 0 0 1-1.6 3.7C17.9 9.2 19 7.9 19 6ZM9 16.5h6V19h2.5v2h-11v-2H9v-2.5Z";
// Clears the hub's top edge. The hub's two feeders sit on the horizontal, so
// the space directly above it is always free.
const TROPHY_GAP = 6;
// Outermost ring. The remaining 62px of the box absorbs the largest node plus
// its ring stroke, so nothing clips at the viewBox edge.
const OUTER_R = 318;
const HUB_R = 34;
// Nodes grow outward so the crowded outer ring still reads at small sizes.
const NODE_MIN = 14.2;
const NODE_STEP = 2.2;
// Initials floor, in viewBox units. The stage never goes below 32rem against a
// 760-unit box (scale ~0.674), so 15 units is ~10px on screen — under that, two
// letters are a smudge. `node.r * 0.8` alone puts the inner ring at 7.6px.
const INITIALS_MIN = 15;
// Siblings pull slightly toward their parent, opening a lane between subtrees.
const SIBLING_GAP = 0.9;
// Puts the hub's two feeders on the horizontal, where there's room for them.
const HUB_ANGLE = 90;
// Module scope so the memoized marks keep a stable transition identity.
const DIM_TRANSITION = { duration: 0.18 } as const;
const NO_TRANSITION = { duration: 0 } as const;
// Math.sin/cos are implementation-defined down in the last digits, so the SSR
// engine and the browser disagree and React reports a hydration mismatch on
// every coordinate. Quantizing well below sub-pixel makes both agree exactly.
const quantize = (n: number) => Math.round(n * 1e3) / 1e3;
const polar = (radius: number, deg: number) => {
const rad = (deg * Math.PI) / 180;
return {
x: quantize(CENTER + radius * Math.cos(rad)),
y: quantize(CENTER + radius * Math.sin(rad)),
};
};
const point = (radius: number, deg: number) => {
const { x, y } = polar(radius, deg);
return `${x.toFixed(2)} ${y.toFixed(2)}`;
};
// Fixed precision so the server and client render byte-identical style strings.
// Raw floats serialize differently across the two and trip a hydration mismatch.
const pct = (value: number) => `${((value / SIZE) * 100).toFixed(4)}%`;
type WheelNode = {
id: string;
parentId: string | null;
depth: number;
/** Position around the wheel, in degrees. Orders arrow-key navigation. */
angle: number;
x: number;
y: number;
r: number;
team: Team | null;
label: string;
/** Round the node's match belongs to; null on the rim, which holds teams. */
round: string | null;
};
type WheelLink = {
id: string;
d: string;
depth: number;
};
// Names and round labels are single ideas, so they wrap as a unit. Without this
// "Round of 16" strands a lone "16" on the next line.
const keepTogether = (text: string) => text.replace(/ /g, " ");
const teamName = (side: MatchSide) => keepTogether(side.team?.name ?? "TBD");
/** A `logo` is used as given; a country `code` loads a flag from flagcdn.com. */
const crestSrc = (team: Team) =>
team.logo ?? (team.code ? `https://flagcdn.com/w80/${team.code}.png` : null);
/** Two-letter stand-in when a team has no artwork — "Real Madrid" → RM.
* Spread, not `word[0]`: an emoji or astral first character is a surrogate pair
* and indexing it renders a replacement glyph. */
const initials = (name: string) =>
name
.split(/\s+/)
.slice(0, 2)
.map((word) => [...word][0])
.join("")
.toUpperCase();
/** Teams · score, in the order they were played. The round is prepended by the
* caller that has it, so the round list can reuse this without repeating it. */
function matchLabel(match: Match) {
const teams = `${teamName(match.home)} v ${teamName(match.away)}`;
if (match.home.score == null || match.away.score == null) return teams;
const pens =
match.home.penalties != null && match.away.penalties != null
? ` (${match.home.penalties}–${match.away.penalties} pens)`
: "";
return `${teams} · ${match.home.score}–${match.away.score}${pens}`;
}
/** Walks the match tree from the final outward, laying every node on a ring and
* splitting each parent's wedge between its two feeders. */
function buildWheel(rounds: Round[]) {
const nodes: WheelNode[] = [];
const links: WheelLink[] = [];
const layers = rounds.length;
const ringR = (depth: number) => (depth / layers) * OUTER_R;
const nodeR = (depth: number) => NODE_MIN + (depth - 1) * NODE_STEP;
// An empty or malformed catalog renders nothing rather than throwing on the
// way to the hub.
const final = rounds[layers - 1]?.matches[0];
if (!final) return { nodes, links, champion: null };
const champion = final.winner ? final[final.winner].team : null;
nodes.push({
id: final.id,
parentId: null,
depth: 0,
angle: HUB_ANGLE,
x: CENTER,
y: CENTER,
r: HUB_R,
team: champion,
label: matchLabel(final),
round: rounds[layers - 1].name,
});
// `roundIndex` is the round `match` belongs to; its two feeders live one round
// out, or, past the first round, are the two teams that played it.
const walk = (
match: Match,
roundIndex: number,
index: number,
parent: WheelNode,
angle: number,
wedge: number,
) => {
const depth = parent.depth + 1;
const radius = ringR(depth);
const r = nodeR(depth);
// The hub's two feeders sit opposite each other, so they get plain radial
// lines; an arc between them would be a half circle.
const offset = (wedge / 4) * (parent.depth === 0 ? 1 : SIBLING_GAP);
const angles = [angle - offset, angle + offset];
const sides = ["home", "away"] as const;
const children = angles.map((childAngle, side) => {
const { x, y } = polar(radius, childAngle);
const feeder =
roundIndex > 0
? rounds[roundIndex - 1].matches[2 * index + side]
: undefined;
const node: WheelNode = feeder
? {
id: feeder.id,
parentId: parent.id,
depth,
angle: childAngle,
x,
y,
r,
team: feeder.winner ? feeder[feeder.winner].team : null,
label: matchLabel(feeder),
round: rounds[roundIndex - 1].name,
}
: {
id: `${match.id}-${sides[side]}`,
parentId: parent.id,
depth,
angle: childAngle,
x,
y,
r,
team: match[sides[side]].team,
label: match[sides[side]].team?.name ?? "TBD",
round: null,
};
nodes.push(node);
return { node, angle: childAngle, feeder };
});
if (parent.depth === 0) {
for (const child of children) {
links.push({
id: `${parent.id}-${child.node.id}`,
d: `M ${point(radius, child.angle)} L ${CENTER} ${CENTER}`,
depth,
});
}
} else {
const midR = (ringR(parent.depth) + radius) / 2;
links.push({
id: `${parent.id}-arc`,
d: `M ${point(radius, angles[0])} L ${point(midR, angles[0])} A ${midR} ${midR} 0 0 1 ${point(midR, angles[1])} L ${point(radius, angles[1])}`,
depth,
});
links.push({
id: `${parent.id}-stem`,
d: `M ${point(midR, angle)} L ${point(ringR(parent.depth), angle)}`,
depth,
});
}
for (const [side, child] of children.entries()) {
if (child.feeder) {
walk(
child.feeder,
roundIndex - 1,
2 * index + side,
child.node,
child.angle,
wedge / 2,
);
}
}
};
walk(final, layers - 1, 0, nodes[0], HUB_ANGLE, 360);
return { nodes, links, champion };
}
/** Match ids from the hub down to the champion's first-round win. */
function championPath(rounds: Round[]) {
const path = new Set<string>();
let index = 0;
for (let r = rounds.length - 1; r >= 0; r--) {
const match = rounds[r].matches[index];
if (!match?.winner) return path;
path.add(match.id);
if (r === 0) path.add(`${match.id}-${match.winner}`);
index = 2 * index + (match.winner === "home" ? 0 : 1);
}
return path;
}
function TeamMark({
node,
clipId,
lit,
dimmed,
loadFlag,
transition,
}: {
node: WheelNode;
clipId: string;
lit: boolean;
dimmed: boolean;
loadFlag: boolean;
transition: object;
}) {
// The failed URL, not a boolean: a corrected logo on the same node should be
// tried again rather than stay initials for the life of the wheel.
const [failedSrc, setFailedSrc] = useState<string | null>(null);
const resolved = node.team ? crestSrc(node.team) : null;
const src = resolved === failedSrc ? null : resolved;
const showFlag = src != null && loadFlag;
// A square logo is fitted whole; a 4:3 flag is cropped to fill the disc.
const box = node.team?.logo
? { w: node.r * 1.44, h: node.r * 1.44, fit: "xMidYMid meet" }
: { w: node.r * 2.68, h: node.r * 2, fit: "xMidYMid slice" };
// Dimming rides on the mark itself rather than a scrim tinted with the page
// background, so the wheel recedes correctly on any surface it's dropped on.
const fade = { opacity: dimmed ? 0.38 : 1 };
return (
<>
{/* Stays opaque at every state: links are routed underneath and would
otherwise read straight through the flag. */}
<circle cx={node.x} cy={node.y} r={node.r} className="fill-card" />
{showFlag && node.team ? (
<>
<clipPath id={clipId}>
<circle cx={node.x} cy={node.y} r={node.r} />
</clipPath>
{/* Plain <image> — flags from flagcdn.com, logos from wherever you host
them. A 4:3 flag is cropped to fill the disc; a logo is fitted whole
inside it, since a crest cropped to a circle loses its shape. */}
<motion.image
href={src}
x={node.x - box.w / 2}
y={node.y - box.h / 2}
width={box.w}
height={box.h}
clipPath={`url(#${clipId})`}
preserveAspectRatio={box.fit}
initial={false}
animate={fade}
transition={transition}
onError={() => setFailedSrc(src)}
/>
</>
) : node.team ? (
// No artwork on this team — initials keep the ring readable.
<motion.text
x={node.x}
y={node.y}
textAnchor="middle"
dominantBaseline="central"
fontSize={Math.max(node.r * 0.8, INITIALS_MIN)}
initial={false}
animate={fade}
transition={transition}
className="fill-muted-foreground font-semibold"
>
{initials(node.team.name)}
</motion.text>
) : (
// Same shield the knockout bracket uses for a TBD slot, so an
// undecided place reads identically across both fixture styles.
<motion.g initial={false} animate={fade} transition={transition}>
<Shield
x={node.x - node.r * 0.7}
y={node.y - node.r * 0.7}
width={node.r * 1.4}
height={node.r * 1.4}
className="fill-current text-muted-foreground/50"
/>
</motion.g>
)}
<circle
cx={node.x}
cy={node.y}
r={node.r}
fill="none"
strokeWidth={lit ? 2 : 1}
className={lit ? "stroke-foreground" : "stroke-border"}
/>
</>
);
}
/** Memoized so pointing at one flag re-renders two marks, not all 63. */
const WheelMark = memo(function WheelMark({
node,
isLit,
dimmed,
loadFlag,
reduce,
enter,
showTrophy,
clipId,
}: {
node: WheelNode;
isLit: boolean;
dimmed: boolean;
loadFlag: boolean;
reduce: boolean;
enter: object;
showTrophy: boolean;
clipId: string;
}) {
return (
<motion.g
initial={reduce ? false : { opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
transition={enter}
style={{ transformOrigin: `${node.x}px ${node.y}px` }}
>
{showTrophy && (
<g
transform={`translate(${CENTER - TROPHY_SIZE / 2}, ${CENTER - HUB_R - TROPHY_GAP - TROPHY_SIZE})`}
>
<path d={TROPHY_PATH} className="fill-warning" />
</g>
)}
<TeamMark
node={node}
clipId={clipId}
lit={isLit}
dimmed={dimmed}
loadFlag={loadFlag}
transition={reduce ? NO_TRANSITION : DIM_TRANSITION}
/>
</motion.g>
);
});
/** Invisible hit area over one flag: hover, tap, focus and arrow keys. */
const WheelAnchor = memo(function WheelAnchor({
node,
caption,
isTabStop,
isPinned,
canHover,
uid,
onHover,
onFocusNode,
onToggle,
onKey,
}: {
node: WheelNode;
caption: string;
isTabStop: boolean;
isPinned: boolean;
canHover: boolean;
uid: string;
onHover: (id: string | null) => void;
onFocusNode: (id: string | null) => void;
onToggle: (id: string) => void;
onKey: (node: WheelNode, key: string) => void;
}) {
const size = pct(node.r * 2);
// Capture-phase focus props, so a Tooltip cloning the child cannot overwrite
// them. Both interaction paths are always attached: iPadOS answers the hover
// query with true for a finger, so hanging the tap path off "cannot hover"
// left it unreachable on the very device it was written for. The event says
// which input arrived.
const tap = useTapGesture<boolean>();
const hover = useHoverGesture();
const trigger = (
<button
type="button"
id={`${uid}-${node.id}`}
tabIndex={isTabStop ? 0 : -1}
aria-label={caption}
onKeyDown={(event: KeyboardEvent) => {
// A key press starts a keyboard activation, which never had a pointer
// behind it: a gesture the platform took away must not be read as the
// tap behind the click this press synthesizes.
tap.drop();
if (!event.key.startsWith("Arrow")) return;
// Arrows drive the wheel here, so they must not also scroll the page.
event.preventDefault();
onKey(node, event.key);
}}
onFocusCapture={() => onFocusNode(node.id)}
onBlurCapture={() => onFocusNode(null)}
onPointerEnter={(event) => {
if (hover.enter(event)) onHover(node.id);
}}
onPointerLeave={(event) => {
if (hover.leave(event)) onHover(null);
}}
onPointerDown={(event) => {
tap.start(event, isPinned);
}}
onPointerCancel={tap.drop}
// Click, not pointerdown: a tap focuses the button first, and unpinning
// has to also drop that focus or the flag stays lit.
onClick={(event) => {
const gesture = tap.take();
// A hovering pointer lit the flag on its way in and puts it out on the
// way past; only a gesture without a hover pins one.
if (!gesture || gesture.pointerType === "mouse") return;
onToggle(node.id);
if (gesture.state) event.currentTarget.blur();
}}
// ring-foreground, not the ring token: --ring is a 10% white hairline
// that disappears over a flag. Focus has to be obvious.
className="block h-full w-full rounded-full outline-none focus-visible:ring-2 focus-visible:ring-foreground focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
);
return (
<div
className="absolute"
style={{
left: pct(node.x - node.r),
top: pct(node.y - node.r),
width: size,
height: size,
}}
>
{/* Touch never opens a Tooltip, so those devices skip mounting one per
flag and read the tapped label instead. */}
{canHover ? (
<Tooltip
content={caption}
side="top"
wrapperClassName="block h-full w-full"
className="max-w-[20rem] whitespace-normal text-balance break-words text-center"
>
{trigger}
</Tooltip>
) : (
trigger
)}
</div>
);
});
export function KnockoutWheel({
rounds,
initialRound = 0,
className,
}: KnockoutWheelProps) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const uid = useId().replace(/:/g, "");
const ref = useRef<SVGSVGElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
// 63 flags for a 32-team draw, and SVG <image> has no lazy attribute — so the
// requests wait until the wheel is nearly on screen.
const inView = useInView(ref, { once: true, margin: "300px" });
// Hover, tap and focus are tracked apart. Sharing one slot let a stray mouse
// move clear the isolation while a node still held focus, and a tap has to
// outlive the pointerleave that a finger fires the moment it lifts.
const [hovered, setHovered] = useState<string | null>(null);
const [pinned, setPinned] = useState<string | null>(null);
const [focused, setFocused] = useState<string | null>(null);
const active = hovered ?? pinned ?? focused;
// Everything downstream reads the trimmed catalog, so the kept round's own
// teams become the rim and the sr-only list matches what's drawn.
const visible = useMemo(() => {
const from = Math.min(Math.max(initialRound, 0), Math.max(rounds.length - 1, 0));
return from > 0 ? rounds.slice(from) : rounds;
}, [rounds, initialRound]);
const { nodes, links, champion } = useMemo(
() => buildWheel(visible),
[visible],
);
const winners = useMemo(() => championPath(visible), [visible]);
const activeNode = useMemo(
() => nodes.find((node) => node.id === active),
[active, nodes],
);
// Pointing at one flag isolates that flag. Only at rest does the wheel fall
// back to lighting the champion's whole run.
const lit = useMemo(
() => (activeNode ? new Set([activeNode.id]) : winners),
[activeNode, winners],
);
// Stable identity, or every mark re-renders on each pointer move.
const enter = useMemo(
() =>
reduce
? { duration: 0, opacity: { duration: 0 } }
: { ...SPRING_PANEL, opacity: { duration: 0.24 } },
[reduce],
);
// One transition object per ring, cached, so the ring-by-ring entrance delay
// survives memoization instead of allocating 63 objects per render.
const enterFor = useMemo(() => {
const cache = new Map<number, object>();
return (depth: number) => {
const hit = cache.get(depth);
if (hit) return hit;
const value = { ...enter, delay: reduce ? 0 : depth * 0.06 };
cache.set(depth, value);
return value;
};
}, [enter, reduce]);
// Tooltip is hover-only by design, so touch gets the same label anchored to
// the tapped flag. It flips to the far side near the rim so it stays on stage.
const tapped = canHover ? undefined : activeNode;
const tapAbove = tapped ? tapped.y > CENTER : false;
// Arrow keys follow the geometry: up walks toward the hub, down walks out to
// a feeder, left/right go round the ring.
const { ring, firstChild } = useMemo(() => {
const byDepth = new Map<number, WheelNode[]>();
const child = new Map<string, WheelNode>();
for (const node of nodes) {
const peers = byDepth.get(node.depth) ?? [];
peers.push(node);
byDepth.set(node.depth, peers);
if (node.parentId && !child.has(node.parentId)) {
child.set(node.parentId, node);
}
}
for (const peers of byDepth.values()) {
peers.sort((a, b) => a.angle - b.angle);
}
return { ring: byDepth, firstChild: child };
}, [nodes]);
const tabStop = activeNode?.id ?? nodes[0]?.id;
// Stable callbacks so the memoized anchors don't re-render on every hover.
const onKey = useCallback(
(node: WheelNode, key: string) => {
if (!key.startsWith("Arrow")) return;
const target =
key === "ArrowUp"
? nodes.find((peer) => peer.id === node.parentId)
: key === "ArrowDown"
? firstChild.get(node.id)
: (() => {
const peers = ring.get(node.depth) ?? [];
if (peers.length < 2) return undefined;
const at = peers.indexOf(node);
const next = key === "ArrowRight" ? at + 1 : at - 1;
return peers[(next + peers.length) % peers.length];
})();
if (!target) return;
// Focus moves; the focus handler lights the node it lands on.
document.getElementById(`${uid}-${target.id}`)?.focus();
},
[nodes, ring, firstChild, uid],
);
const onToggle = useCallback(
(id: string) => setPinned((current) => (current === id ? null : id)),
[],
);
// A tap on another flag hands the isolation over rather than ending it.
const onFlag = useCallback(
(target: Element) =>
Boolean(stageRef.current?.contains(target) && target.closest("button")),
[],
);
// A finger never leaves the flag it lit, so the isolation would hold for good
// — and bare stage reports no pointer event of its own to end it. The next
// pointerdown that isn't on a flag stands in for the pointer leaving; it is
// consumed, since a wheel spanning the viewport makes tapping past it the
// natural way out and there is no reason for that tap to do anything else.
// Only a pinned flag arms this: a mouse ends its own hover, and the browser
// drops focus on its own.
const unpin = useCallback(() => {
setPinned(null);
const focus = document.activeElement;
if (focus instanceof HTMLElement && stageRef.current?.contains(focus)) {
focus.blur();
}
}, []);
useDismiss(pinned !== null, unpin, null, {
behavior: "consume",
ignore: onFlag,
});
// Round · teams · score for a decided match; a rim node is just a team. A slot
// whose team isn't known yet reads TBD, matching the shield drawn in its place.
const captions = useMemo(
() =>
new Map(
nodes.map((node) => [
node.id,
node.team == null
? "TBD"
: node.round
? `${keepTogether(node.round)} · ${node.label}`
: node.team.name,
]),
),
[nodes],
);
return (
<div
className={cn(
"w-full max-w-full overflow-x-auto overscroll-x-contain",
className,
)}
>
{/* Below the min width the rim's marks collapse too small to tell apart or
tap, so the wheel holds its size and pans instead. The floor is fixed,
not rim-derived: node radius grows with depth, so a shallower draw has
*smaller* marks and needs the width more, not less. */}
<div
ref={stageRef}
className="relative mx-auto w-full min-w-[32rem] max-w-[34rem]"
>
<svg
ref={ref}
viewBox={`0 0 ${SIZE} ${SIZE}`}
role="img"
aria-label={`Tournament wheel${champion ? `, won by ${champion.name}` : ""}`}
className="h-auto w-full touch-manipulation"
>
<defs>
{/* Warm halo marking the champion. Kept faint: --warning is saturated
enough that anything stronger drowns the connectors under it. */}
<radialGradient id={`${uid}-glow`}>
<stop offset="0%" stopColor="var(--color-warning)" stopOpacity="0.14" />
<stop offset="45%" stopColor="var(--color-warning)" stopOpacity="0.04" />
<stop offset="100%" stopColor="var(--color-warning)" stopOpacity="0" />
</radialGradient>
</defs>
{champion && (
<circle
cx={CENTER}
cy={CENTER}
r={OUTER_R * 0.62}
fill={`url(#${uid}-glow)`}
/>
)}
{/* role="img" on the svg prunes descendants from the accessibility tree,
so the structure needs no aria-hidden of its own. */}
<g>
{links.map((link) => (
<motion.path
key={link.id}
d={link.d}
fill="none"
initial={reduce ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: link.depth * 0.06 }}
className="stroke-border-strong"
/>
))}
</g>
{nodes.map((node) => (
<WheelMark
key={node.id}
node={node}
isLit={lit.has(node.id)}
dimmed={lit.size > 0 && !lit.has(node.id)}
loadFlag={inView}
reduce={Boolean(reduce)}
enter={enterFor(node.depth)}
showTrophy={node.depth === 0 && champion != null}
clipId={`${uid}-clip-${node.id}`}
/>
))}
</svg>
{/* An SVG <g> can't anchor a Tooltip, so each flag gets an invisible
HTML hit area laid over it in percentage units, which track the
wheel as it scales. */}
{nodes.map((node) => (
<WheelAnchor
key={node.id}
node={node}
caption={captions.get(node.id) ?? ""}
isTabStop={node.id === tabStop}
isPinned={node.id === pinned}
canHover={canHover}
uid={uid}
onHover={setHovered}
onFocusNode={setFocused}
onToggle={onToggle}
onKey={onKey}
/>
))}
{tapped && (
<motion.p
initial={reduce ? false : { opacity: 0, scale: 0.94 }}
animate={{ opacity: 1, scale: 1 }}
transition={reduce ? NO_TRANSITION : DIM_TRANSITION}
style={{
left: pct(tapped.x),
top: pct(tapped.y + (tapAbove ? -tapped.r : tapped.r)),
}}
className={cn(
"pointer-events-none absolute z-10 w-max max-w-[min(18rem,80vw)] -translate-x-1/2 text-balance break-words rounded-lg border border-border bg-background px-2.5 py-1 text-center text-xs font-medium text-foreground shadow-lg",
tapAbove ? "-translate-y-[calc(100%+8px)]" : "translate-y-2",
)}
>
{captions.get(tapped.id)}
</motion.p>
)}
</div>
{/* role="img" prunes the svg's descendants, so every result is restated
here for screen readers. */}
{/* Named lists, not <section aria-label> — a named section is a landmark,
and five of those would crowd real page landmarks. */}
<div className="sr-only">
{visible
.slice()
.reverse()
.map((round) => (
<ul key={round.name} aria-label={round.name}>
{round.matches.map((match) => (
<li key={match.id}>{matchLabel(match)}</li>
))}
</ul>
))}
</div>
</div>
);
}
// ── Sample data ──────────────────────────────────────────────────────────────
// A finished 32-team cup, here to demo the shape. Swap it for your own
// tournament. Rounds run widest first and each holds half as many matches as the
// one before it (16 → 8 → 4 → 2 → 1); `matches[k]` of a round is fed by matches
// `2k` and `2k + 1` of the round before it, which is what pairs the branches.
// Any draw works: pass fewer rounds for a smaller cup, give teams a `logo`
// instead of a country `code`, or neither for initials. The knockout bracket
// takes the same array, so one dataset feeds both fixture styles.
export const TEAMS = {
spain: { name: "Spain", code: "es" },
japan: { name: "Japan", code: "jp" },
netherlands: { name: "Netherlands", code: "nl" },
portugal: { name: "Portugal", code: "pt" },
england: { name: "England", code: "gb-eng" },
uruguay: { name: "Uruguay", code: "uy" },
croatia: { name: "Croatia", code: "hr" },
brazil: { name: "Brazil", code: "br" },
france: { name: "France", code: "fr" },
morocco: { name: "Morocco", code: "ma" },
belgium: { name: "Belgium", code: "be" },
italy: { name: "Italy", code: "it" },
argentina: { name: "Argentina", code: "ar" },
mexico: { name: "Mexico", code: "mx" },
germany: { name: "Germany", code: "de" },
norway: { name: "Norway", code: "no" },
costaRica: { name: "Costa Rica", code: "cr" },
serbia: { name: "Serbia", code: "rs" },
ecuador: { name: "Ecuador", code: "ec" },
ghana: { name: "Ghana", code: "gh" },
wales: { name: "Wales", code: "gb-wls" },
canada: { name: "Canada", code: "ca" },
denmark: { name: "Denmark", code: "dk" },
cameroon: { name: "Cameroon", code: "cm" },
poland: { name: "Poland", code: "pl" },
senegal: { name: "Senegal", code: "sn" },
tunisia: { name: "Tunisia", code: "tn" },
switzerland: { name: "Switzerland", code: "ch" },
peru: { name: "Peru", code: "pe" },
qatar: { name: "Qatar", code: "qa" },
sweden: { name: "Sweden", code: "se" },
austria: { name: "Austria", code: "at" },
} satisfies Record<string, Team>;
export const ROUNDS: Round[] = [
{
name: "Round of 32",
matches: [
{
id: "w-r32-1",
home: { team: TEAMS.spain, score: 3 },
away: { team: TEAMS.costaRica, score: 0 },
winner: "home",
},
{
id: "w-r32-2",
home: { team: TEAMS.japan, score: 2 },
away: { team: TEAMS.serbia, score: 1 },
winner: "home",
},
{
id: "w-r32-3",
home: { team: TEAMS.netherlands, score: 2 },
away: { team: TEAMS.ecuador, score: 0 },
winner: "home",
},
{
id: "w-r32-4",
home: { team: TEAMS.ghana, score: 2 },
away: { team: TEAMS.portugal, score: 3 },
winner: "away",
},
{
id: "w-r32-5",
home: { team: TEAMS.england, score: 4 },
away: { team: TEAMS.wales, score: 0 },
winner: "home",
},
{
id: "w-r32-6",
home: { team: TEAMS.canada, score: 0 },
away: { team: TEAMS.uruguay, score: 2 },
winner: "away",
},
{
id: "w-r32-7",
home: { team: TEAMS.croatia, score: 1 },
away: { team: TEAMS.denmark, score: 0 },
winner: "home",
},
{
id: "w-r32-8",
home: { team: TEAMS.brazil, score: 3 },
away: { team: TEAMS.cameroon, score: 1 },
winner: "home",
},
{
id: "w-r32-9",
home: { team: TEAMS.france, score: 2 },
away: { team: TEAMS.poland, score: 1 },
winner: "home",
},
{
id: "w-r32-10",
home: { team: TEAMS.senegal, score: 0 },
away: { team: TEAMS.morocco, score: 1 },
winner: "away",
},
{
id: "w-r32-11",
home: { team: TEAMS.belgium, score: 2 },
away: { team: TEAMS.tunisia, score: 0 },
winner: "home",
},
{
id: "w-r32-12",
home: { team: TEAMS.switzerland, score: 1 },
away: { team: TEAMS.italy, score: 3 },
winner: "away",
},
{
id: "w-r32-13",
home: { team: TEAMS.argentina, score: 2 },
away: { team: TEAMS.peru, score: 0 },
winner: "home",
},
{
id: "w-r32-14",
home: { team: TEAMS.qatar, score: 0 },
away: { team: TEAMS.mexico, score: 1 },
winner: "away",
},
{
id: "w-r32-15",
home: { team: TEAMS.germany, score: 4 },
away: { team: TEAMS.sweden, score: 2 },
winner: "home",
},
{
id: "w-r32-16",
home: { team: TEAMS.austria, score: 1 },
away: { team: TEAMS.norway, score: 2 },
winner: "away",
},
],
},
{
name: "Round of 16",
matches: [
{
id: "w-r16-1",
home: { team: TEAMS.spain, score: 2 },
away: { team: TEAMS.japan, score: 0 },
winner: "home",
},
{
id: "w-r16-2",
home: { team: TEAMS.netherlands, score: 1 },
away: { team: TEAMS.portugal, score: 3 },
winner: "away",
},
{
id: "w-r16-3",
home: { team: TEAMS.england, score: 2 },
away: { team: TEAMS.uruguay, score: 1 },
winner: "home",
},
{
id: "w-r16-4",
home: { team: TEAMS.croatia, score: 0 },
away: { team: TEAMS.brazil, score: 1 },
winner: "away",
},
{
id: "w-r16-5",
home: { team: TEAMS.france, score: 3 },
away: { team: TEAMS.morocco, score: 1 },
winner: "home",
},
{
id: "w-r16-6",
home: { team: TEAMS.belgium, score: 1 },
away: { team: TEAMS.italy, score: 2 },
winner: "away",
},
{
id: "w-r16-7",
home: { team: TEAMS.argentina, score: 2 },
away: { team: TEAMS.mexico, score: 0 },
winner: "home",
},
{
id: "w-r16-8",
home: { team: TEAMS.germany, score: 1, penalties: 4 },
away: { team: TEAMS.norway, score: 1, penalties: 2 },
winner: "home",
},
],
},
{
name: "Quarter-finals",
matches: [
{
id: "w-qf-1",
home: { team: TEAMS.spain, score: 1 },
away: { team: TEAMS.portugal, score: 0 },
winner: "home",
},
{
id: "w-qf-2",
home: { team: TEAMS.england, score: 2 },
away: { team: TEAMS.brazil, score: 3 },
winner: "away",
},
{
id: "w-qf-3",
home: { team: TEAMS.france, score: 2 },
away: { team: TEAMS.italy, score: 1 },
winner: "home",
},
{
id: "w-qf-4",
home: { team: TEAMS.argentina, score: 3 },
away: { team: TEAMS.germany, score: 1 },
winner: "home",
},
],
},
{
name: "Semi-finals",
matches: [
{
id: "w-sf-1",
home: { team: TEAMS.spain, score: 2 },
away: { team: TEAMS.brazil, score: 1 },
winner: "home",
},
{
id: "w-sf-2",
home: { team: TEAMS.france, score: 0, penalties: 3 },
away: { team: TEAMS.argentina, score: 0, penalties: 4 },
winner: "away",
},
],
},
{
name: "Final",
matches: [
{
id: "w-f-1",
home: { team: TEAMS.spain, score: 2 },
away: { team: TEAMS.argentina, score: 1 },
winner: "home",
},
],
},
];
"use client";
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import {
cloneElement,
isValidElement,
type PointerEvent,
type ReactElement,
type ReactNode,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { EASE_OUT } from "@/lib/ease";
import { useDismiss } from "@/lib/hooks/use-dismiss";
import { useHoverGesture } from "@/lib/hooks/use-hover-gesture";
import { useTapGesture } from "@/lib/hooks/use-tap-gesture";
import { cn } from "@/lib/utils";
type Side = "top" | "right" | "bottom" | "left";
export interface TooltipProps {
content: ReactNode;
children: ReactElement;
side?: Side;
/** Delay before showing (ms). Default 120. */
delay?: number;
className?: string;
/** Classes for the outer wrapper span. Use to fix baseline / fill parent. */
wrapperClassName?: string;
}
// Gap between trigger and tooltip, in px.
const GAP = 8;
// Centering transform for the fixed-positioned anchor point, per side.
const anchorTransform: Record<Side, string> = {
top: "translate(-50%, -100%)",
bottom: "translate(-50%, 0)",
left: "translate(-100%, -50%)",
right: "translate(0, -50%)",
};
const transformOrigin: Record<Side, string> = {
top: "center bottom",
bottom: "center top",
left: "right center",
right: "left center",
};
// Offset is in the direction *away* from the trigger — content originates near
// the trigger and rises into resting position.
const offsetFrom: Record<Side, { x?: number; y?: number }> = {
top: { y: 8 },
bottom: { y: -8 },
left: { x: 8 },
right: { x: -8 },
};
function buildVariants(side: Side): Variants {
const o = offsetFrom[side];
return {
initial: {
opacity: 0,
scale: 0.9,
filter: "blur(5px)",
x: o.x ?? 0,
y: o.y ?? 0,
},
animate: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
x: 0,
y: 0,
transition: {
type: "spring",
stiffness: 380,
damping: 30,
mass: 0.7,
opacity: { duration: 0.14, ease: EASE_OUT },
filter: { duration: 0.18, ease: EASE_OUT },
},
},
exit: {
opacity: 0,
scale: 0.94,
filter: "blur(3px)",
x: (o.x ?? 0) * 0.6,
y: (o.y ?? 0) * 0.6,
transition: { duration: 0.12, ease: EASE_OUT },
},
};
}
const REDUCED_VARIANTS: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1, transition: { duration: 0.14, ease: EASE_OUT } },
exit: { opacity: 0, transition: { duration: 0.1, ease: EASE_OUT } },
};
// Once any tooltip has just closed, neighbouring tooltips open without the
// initial delay — moving along a toolbar feels instant after the first one.
const WARM_WINDOW_MS = 300;
let lastHiddenAt = 0;
export function Tooltip({
content,
children,
side = "top",
delay = 120,
className,
wrapperClassName,
}: TooltipProps) {
const [open, setOpen] = useState(false);
const [coords, setCoords] = useState<{ top: number; left: number } | null>(
null,
);
const id = useId();
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const anchorRef = useRef<HTMLSpanElement>(null);
const hover = useHoverGesture();
const reduce = useReducedMotion();
// Anchor point in viewport coords, on the edge of the trigger facing `side`.
// Position:fixed means these viewport coords place the tooltip directly, so
// it escapes every ancestor's stacking context and overflow.
const place = useCallback(() => {
const el = anchorRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
const point: Record<Side, { top: number; left: number }> = {
top: { top: r.top - GAP, left: cx },
bottom: { top: r.bottom + GAP, left: cx },
left: { top: cy, left: r.left - GAP },
right: { top: cy, left: r.right + GAP },
};
setCoords(point[side]);
}, [side]);
const show = useCallback(() => {
if (timer.current) clearTimeout(timer.current);
const warm = Date.now() - lastHiddenAt < WARM_WINDOW_MS;
timer.current = setTimeout(
() => {
place();
setOpen(true);
},
warm ? 0 : delay,
);
}, [delay, place]);
const hide = useCallback(() => {
if (timer.current) {
clearTimeout(timer.current);
timer.current = null;
}
if (open) lastHiddenAt = Date.now();
setOpen(false);
}, [open]);
// A finger never hovers, and Safari does not focus a button on tap either, so
// the label is only reachable if the tap itself opens the tooltip. A click
// carries no pointerType, so the pointerdown that preceded it is what says
// whether this was a tap; keyboard activation arrives with no pointerdown at
// all, and focus has already shown the label there.
const tap = useTapGesture<boolean>();
const toggleOnTap = useCallback(() => {
const gesture = tap.take();
if (!gesture || gesture.pointerType === "mouse") return;
if (gesture.state) {
hide();
return;
}
if (timer.current) clearTimeout(timer.current);
place();
setOpen(true);
}, [hide, place, tap]);
// ...and closed again by the next tap that lands somewhere else. The label
// covers nothing interactive, so that tap passes through to what it hit.
useDismiss(open, hide, anchorRef);
// Keep the tooltip pinned to the trigger while it's open and the page scrolls
// or resizes (fixed coords are viewport-relative).
useEffect(() => {
if (!open) return;
const onMove = () => place();
window.addEventListener("scroll", onMove, true);
window.addEventListener("resize", onMove);
return () => {
window.removeEventListener("scroll", onMove, true);
window.removeEventListener("resize", onMove);
};
}, [open, place]);
const variants = useMemo(
() => (reduce ? REDUCED_VARIANTS : buildVariants(side)),
[reduce, side],
);
if (!isValidElement(children)) return children;
// The label describes the trigger, so it has to name the trigger itself.
// Everything else the tooltip needs is read off the anchor below instead of
// cloned on: a handler written onto the child is the child's handler as far
// as that child can tell, and a component that owns its activation —
// hard-wiring onClick and spreading the rest of its props over it, as
// ThemeToggle does — then runs the tooltip's instead of its own. Composing
// with `props.onClick` cannot save it either, because a component element's
// props hold nothing the component does internally.
const trigger = cloneElement(children as ReactElement<Record<string, unknown>>, {
"aria-describedby": id,
});
return (
<>
{/* biome-ignore lint/a11y/noStaticElementInteractions: the anchor is not a
control — it observes the trigger it wraps. Every event listed reaches
it on its own (pointerdown/click/keydown/pointercancel bubble, focus
and blur arrive as focusin/focusout, and enter/leave are derived from
pointerover/pointerout along a path the anchor is on), so the trigger
keeps every handler it came with. */}
<span
ref={anchorRef}
className={cn("relative inline-flex align-middle", wrapperClassName)}
// Pointer events, not the mouse pair: a tap fires compatibility
// mouseenter/mouseleave that carry no pointerType, which raced the tap
// path into opening and closing the same label.
onPointerEnter={(event: PointerEvent) => {
if (hover.enter(event)) show();
}}
onPointerLeave={(event: PointerEvent) => {
if (hover.leave(event)) hide();
}}
onFocus={show}
onBlur={hide}
onPointerDown={(event: PointerEvent) => tap.start(event, open)}
// A gesture the platform took away sends no click, and a key press
// starts an activation that never had a pointer behind it. Either way
// the record has to go, or the next click reads a finger that has long
// since lifted.
onPointerCancel={tap.drop}
onKeyDown={tap.drop}
onClick={toggleOnTap}
>
{trigger}
</span>
{typeof document !== "undefined"
? createPortal(
<AnimatePresence>
{open && coords ? (
<span
aria-hidden
className="pointer-events-none fixed z-[9999]"
style={{
top: coords.top,
left: coords.left,
transform: anchorTransform[side],
}}
>
<motion.span
id={id}
role="tooltip"
variants={variants}
initial="initial"
animate="animate"
exit="exit"
style={{ transformOrigin: transformOrigin[side] }}
className={cn(
"block whitespace-nowrap rounded-lg border border-border bg-background px-2.5 py-1 text-xs font-medium text-foreground shadow-lg",
className,
)}
>
{content}
</motion.span>
</span>
) : null}
</AnimatePresence>,
document.body,
)
: null}
</>
);
}
API Reference
rounds{}The whole draw, ordered widest round first. Any single-elimination tournament fits: each round holds half the matches of the one before it (16 → 8 → 4 → 2 → 1) and `rounds[r].matches[k]` is fed by matches `2k` and `2k + 1` of the round before it. Two rounds are enough.
—initialRound?numberRound shown as the leftmost column on mount. Defaults to 1, clamped to the valid range.
1thirdPlace?MatchThird place play-off, rendered under the bracket instead of inside it.
—thirdPlaceLabel?stringHeading over `thirdPlace`. Defaults to "Third place play-off".
Third place play-offclassName?string—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















