Rank shields
NewGame-style rank emblems in SVG: a battlemented shield with wings and an embossed number, in five metals, with a hover tilt and an unlock.
rank-shields/rank-shields.tsxThe live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- A rank or place earned in a game or a challenge: the top three of a leaderboard, a season tier, a level reached.
- A moment worth celebrating: the unlock (drop, wings, numeral, sparkles) when a rank is earned, or replayed on demand.
- A set of achievements still to earn, shown
lockedin grey until each is unlocked. - An emblem that has to stay sharp at any size, from a 48 px list row to a full-width podium: it is one SVG.
When not to use
- A count or a status in a dense interface. Use a badge or a pill; the emblem is art and wants room.
- Progress towards the next rank. Use a progress bar or a ring, and keep the shield for the rank itself.
- A long ranked list. Use a table with the place as a number; a shield on every row turns noisy past the first few.
- Serious work tools, where a game metaphor reads as a gimmick. Use a plain number or a medal icon.
- A rank with more than four figures: it is scaled down to fit the panel and stops reading at a glance. Show the number as text.
You can add this component with the shadcn CLI or by hand:
Into components/rank-shields of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/rank-shields.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { RankShield } from './rank-shields';export function LeaderboardRow({ place, unlocked }: { place: number; unlocked: boolean }) { const tier = place === 1 ? 'gold' : place === 2 ? 'silver' : 'bronze'; return <RankShield rank={place} tier={tier} size={96} locked={!unlocked} />;}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
A podium of rank emblems, gold 1, silver 2 and bronze 3, each a battlemented shield with a bevelled panel, a heavy rounded numeral and two pairs of wings under a thick outline. Hover one and it leans towards the pointer as a band of light sweeps the metal; press it and it unlocks again: it drops in, the wings unfold, the number pops and sparkles flash. Any number, in gold, silver, bronze, platinum or diamond.
- The emblem is one SVG drawn on a 461 × 340 grid, layered like a cut-out sticker: one thick outline under everything (the frame and all four wings stroked together at 22 units with round joins, so the gaps between the wings fill in), then the wings, the frame, its bevels, the recessed panel and the numeral. A drop shadow filter sits it on the stage.
- Symmetric shapes are written as their left half only; a small helper mirrors the segments to close each path, and the right wings are the left ones under a mirror transform, so both sides always match.
- The bevels are copies of the same paths: the frame’s light caught along its top edges is a stroke clipped to the frame, its inner lip is the panel shifted down and right in a lighter tone, the panel’s rim shadow is the panel shifted the other way inside a clip, and a mask leaves a dark edge down the frame’s right side. The left half of the frame is lit and the right half shaded by a hard-stop gradient.
- The numerals are hand-drawn outlines for 0 to 9 in a heavy, rounded style, each drawn three times: its drop (shifted down and right), its lit edge, and its face shifted inside a clip of itself. Numbers of two figures or more are scaled down to fit the panel.
- Each metal is one palette on the same drawing: outline, lit and shaded frame, bevel, lip, panel (shared by the wings), rim, wing shade, and the numeral’s three tones.
- Hover tilts the emblem’s HTML box with a perspective rotation, up to 14° towards the pointer, written to custom properties without re-rendering. Entering it, or focusing it from the keyboard, sweeps a skewed band of light across the metal with the Web Animations API, clipped to the frame and wings.
- The unlock remounts the drawing, which restarts its CSS keyframes: the shield drops 46 units and settles in 620 ms, the upper wings unfold from behind it at 320 ms and the lower ones at 410 ms (from their inner edge, with an overshoot), the numeral pops at 620 ms, and a glow and five sparkles flash from 780 ms. Everything moves by transform and opacity.
- The demo plays the unlock across the podium, then leans one shield after another as if hovered; any real input hands it to the reader. It pauses off screen and in a hidden tab, and under reduced motion nothing tilts, sweeps or animates.
| Prop | Type | Default | Description |
|---|---|---|---|
rank | number | — | RankShield: the number on the shield. Whole numbers; two figures or more are scaled down to fit. |
tier | 'gold' | 'silver' | 'bronze' | 'platinum' | 'diamond' | 'gold' | RankShield: the metal. |
size | number | string | '100%' | RankShield: its width (a number is px); the height follows, about three quarters of it. |
locked | boolean | false | RankShield: shown grey and dimmed, as not yet earned. Turning it false plays the unlock. |
replay | number | — | RankShield: each new value plays the unlock again. |
shine | number | — | RankShield: each new value sweeps the sheen across, as a hover does. |
pose | { x: number; y: number } | null | — | RankShield: holds the tilt at this pose, -1 to 1 on each axis, instead of following the pointer. |
onPress | () => void | — | RankShield: makes the shield a button, called when pressed. |
label | string | — | RankShield: the accessible name; by default “Rank 1, Gold”. |
shields | { rank: number; tier: Tier }[] | gold 1, silver 2, bronze 3 | RankShields: the emblems on the stage, left to right. |
autoplay | boolean | true | RankShields: while on screen and untouched, replays the unlock and leans the shields now and then. |
colorScheme | 'light' | 'dark' | 'system' | — | RankShields: light or dark stage, or the reader’s; without it, the page’s color-scheme. |
className | string | — | Classes for the root element. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useRef, useState } from 'react';import { RankShield } from './rank-shield';import { TIER_NAMES, type Tier } from './tiers';import { useAutoplay } from './use-autoplay';import styles from './rank-shields.module.css';export { RankShield, type RankShieldProps } from './rank-shield';export { TIERS, type Tier } from './tiers';/* * Rank shields: a podium of game-style rank emblems (gold 1, silver 2, bronze 3) on a dark navy * stage. Each emblem is a RankShield (rank-shield.tsx), any number in any of five metals. * * - Each shield is a button: hovering tilts it towards the pointer and sweeps a sheen across it, * focusing it from the keyboard sweeps the sheen, pressing it plays its unlock again. "Replay" * plays all three, one after another. * - While on screen and untouched, the demo plays the unlock once, then now and then leans one * shield after another as if hovered, sweeping its sheen, and every few rounds plays the unlock * again. Any real input hands it to the reader. Off screen, in a hidden tab or under reduced * motion, nothing plays.// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useId, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent,} from 'react';import { DIGITS, FRAME, GLYPH, MIRROR, PANEL, SPARKLES, STAR, TOP_EDGE, VIEW, WING_LOWER, WING_UPPER,// Copyright (c) 2026 Bruno Delorence. MIT License./* * The drawing of a rank shield, in the units of a 461 × 340 view box whose mirror axis is * x = 230.5. Every symmetric shape is drawn as its left half only; `symmetric()` closes it with * the right half, mirrored. The wings are drawn on the left and mirrored as a whole. */export const VIEW = { width: 461, height: 340 };const AXIS = 461;type Point = [number, number];type Segment = { c?: [Point, Point]; q?: Point; to: Point };const n = (value: number) => +value.toFixed(2);const pt = ([x, y]: Point) => `${n(x)} ${n(y)}`;const flip = ([x, y]: Point): Point => [AXIS - x, y];/** A closed path from its left half: from a point on the axis, down the left, to the axis again. */function symmetric(start: Point, half: Segment[]) { let d = `M${pt(start)}`; for (const segment of half) { if (segment.c) d += `C${pt(segment.c[0])} ${pt(segment.c[1])} ${pt(segment.to)}`; else if (segment.q) d += `Q${pt(segment.q)} ${pt(segment.to)}`;// Copyright (c) 2026 Bruno Delorence. MIT License./* * The metals. Each tier is one set of colours on the same drawing: the outline, the frame's lit * (left) and shaded (right) halves, its bevels, the recessed panel and its rim (the wings share the * panel's colour), and the numeral's highlight, face and drop. */export type Tier = 'gold' | 'silver' | 'bronze' | 'platinum' | 'diamond';export type Palette = { /** The thick outline around everything. */ outline: string; /** The frame, lit half and shaded half. */ frame: string; frameShade: string; /** The light caught by the top edges, and by the frame's inner lip along the bottom. */ bevel: string; lip: string; /** The deep shadow along the frame's right edge, where it meets the wings. */ deep: string; /** The recessed panel (and the wings), and the shadow its rim casts on it. */ panel: string; rim: string;// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden); const observer = new IntersectionObserver(/* Rank shields (rank-shields.tsx, rank-shield.tsx): a navy stage at night, a pale lavender grey by day, with three emblems in a row, as large as the stage allows (a container query), and a small Replay button under them. The emblems' own colours are in tiers.ts: they are art, the same on both schemes. Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes the color-scheme it inherits from the page, or the one its colorScheme prop sets (data-color-scheme). Motion. Everything moves by transform and opacity: the tilt (a 3D rotation of each emblem's HTML box), the sheen (a band of light translated across, clipped to the metal) and the unlock's keyframes on the drawing's groups. */.root { /* The stage. */ --surface: #e9e8f1; /* The Replay button, its words and its edge. */ --ink: #2b2a3d; --button: rgb(255 255 255 / 0.7); --button-hover: #fff; --edge: rgb(30 28 60 / 0.12); --focus: #387bff; position: relative; container-type: inline-size;- Each emblem is one image to assistive technology, named “Rank 1, Gold” (or by its label); the drawing itself is hidden. With onPress it is a real button with that name instead.
- The shields and the Replay button are reachable with Tab, show a focus ring on :focus-visible, and play with Enter or Space; keyboard focus also sweeps the sheen.
- Pressing a shield or Replay is announced through a polite live region (“Rank 1, Gold, unlocked”).
- Under prefers-reduced-motion the tilt, the sheen and the unlock are all off, and the demo never plays; off screen or in a hidden tab it pauses.
- The numeral keeps strong contrast with its panel in every metal, and the thick outline keeps the emblem legible on light and dark stages.