Title cards
NewA game’s big serif title cards: a place, a landmark, a named enemy, a boss and a quest banner with a COMPLETE stamp, each revealed with gathering letters.
title-cards/title-cards.tsxTidewell Sanctum
Keeper Ilsa Morrow
The 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
- Marking a threshold the player just crossed: a named place entered, a landmark found, a boss fight begun.
- A one-off announcement laid across the middle of the screen that the player reads in a second and that leaves on its own.
- Giving events a ranked weight: a location loudest, a landmark plainer, an enemy smaller with a red rule, a boss with a slam and a shake.
- Opening and closing quests, with a stamp that drops onto the quest’s band when it is completed.
- A cutscene or trailer beat that needs a cinematic title over the picture (
baredrops the plate and chips).
When not to use
- Routine feedback such as an item picked up or a save done. Use game toasts in a corner.
- Several messages that may arrive together: a game never stacks two title cards. Use a toast queue or a quest log.
- Text the player must act on or answer. Use a dialog box or a game modal.
- Tracking objectives over time. Use a quest log; this card announces a quest, it does not list its steps.
- Product headings or page titles outside a game. Use the page’s own heading styles.
You can add this component with the shadcn CLI or by hand:
Into components/title-cards of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/title-cards.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 { TitleCards, type TitleCard } from './title-cards';const CARDS: TitleCard[] = [ { id: 'harbor', kind: 'location', tone: 'warm', title: 'Saltmere Harbor' }, { id: 'warden', kind: 'boss', title: 'The Iron Warden', kicker: 'Keeper of the Lower Gate' }, { id: 'gate', kind: 'quest', quest: 'main', title: 'Open the Lower Gate' },];export function Announcer({ card, done }: { card: string; done: boolean }) { return <TitleCards bare cards={CARDS} card={card} completed={done} />;}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.
The titles a game lays across the middle of the screen when the player crosses a threshold. A location’s name glows (cool for a sanctum, warm for a region) over a subline between scroll ornaments; a landmark reads plain under its region; a named enemy gets a red rule; a boss’s epithet comes first, then its name slams in, white with a red edge and glow, and the card shakes. A quest shows its kind’s icon over a pale band, and completing it drops a COMPLETE stamp onto the band. Titles gather from wide letter-spacing as they fade in. Pick a card from the chips, or complete the quest.
- A card is a layer. Showing another card, or the same one again, sends the shown layer out (it fades in 260 ms) and mounts a new one in the same grid cell; the newcomer’s parts wait --wait (260 ms when something is leaving), so two titles never stack, as in a game.
- Each part plays its keyframes as it mounts. A title gathers: it fades in while its letter-spacing and its text-indent shrink together from 0.42 em to 0.02 em (the indent balances the space after the last letter, so the line stays centred). A location’s glow then swells past its resting text-shadow and settles; its subline rises in while the scroll ornaments slide outwards.
- An enemy’s red rule sits in a grid with the name and reaches 0.75 em past it on each side; it draws from the centre (scale from 0 on x) once the name has landed. A boss’s epithet fades in first; the name slams from 1.32× with wide spacing on an overshooting curve, its red glow flares, and the whole card shakes for 320 ms.
- A quest’s icon pops, its label fades, the band opens from the centre and its title gathers on it. The COMPLETE stamp mounts when completed is set: it drops from 2.6× to just under its size and settles, with a flash of warm glow; when the quest arrives already completed, the stamp waits for the band.
- The titles are set in Cinzel, a free inscriptional serif whose lowercase letters are small capitals, loaded once from Google Fonts (a stylesheet link the component adds to the document), with Georgia behind it. The boss’s and the sanctum’s edges are a text stroke painted under the fill (paint-order), and every glow is a text- or box-shadow, so the letters stay sharp.
- Every size is in em of the plate’s font size (60 px wide, down to 26 px on a phone through a container query, or --title-size), so a card scales as one; small text never goes under 11 px. The cards sit on their own warm dark plate, lit a little at its centre, so their colours stay the same on a light or a dark page; only the plate’s edge, its shadow and the chips follow the colour scheme. bare draws a card alone, to lay over a game.
- Uncontrolled, chips under the plate pick a card (the shown one replays) and complete the quest, and a demo plays a short run of play (a sanctum, a region, a landmark, an enemy, a boss, a quest and its completion) while it is on screen and untouched; any input hands it to the reader.
| Prop | Type | Default | Description |
|---|---|---|---|
cards | TitleCard[] | sanctum, region, landmark, enemy, boss, quest | The cards, each { id, kind, title, kicker?, tone?, quest?, questLabel?, label? }. kind is 'location' | 'landmark' | 'enemy' | 'boss' | 'quest'; kicker is a location's subline, a landmark's or an enemy's region, or a boss's epithet; tone ('cool' | 'warm') sets a location's glow; quest ('main' | 'side' | 'trial') picks a quest's icon; label names its chip. |
card | string | — | The id of the card shown, controlled: changing it plays the next card in. The demo is off, and the chips ask onCardChange. |
defaultCard | string | 'sanctum' | Uncontrolled, the card it starts with. |
onCardChange | (card: string) => void | — | Asked when a chip is picked (the shown card’s chip replays it). |
completed | boolean | — | Whether a quest card is completed, controlled: turning it on drops the COMPLETE stamp onto the band. |
onCompletedChange | (completed: boolean) => void | — | Asked when the Complete chip is toggled. |
bare | boolean | false | The card alone, without its plate and chips, to lay over a game. Its titles are 60 px, or --title-size. |
autoplay | boolean | true | While on screen and untouched, a short run of play: each card in turn, then the quest completed. Uncontrolled only. |
colorScheme | 'light' | 'dark' | 'system' | — | Forces light or dark for the plate’s edge and shadow and the chips; by default it follows the page. The cards keep their colours. |
className | string | — | Added to the root. |
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 { useCallback, useEffect, useEffectEvent, useLayoutEffect, useRef, useState, type CSSProperties,} from 'react';import { LeafDash, QuestIcon, ScrollEnd, type QuestKind } from './ornaments';import { useAutoplay } from './use-autoplay';import styles from './title-cards.module.css';/* * Title cards: the big serif titles a game lays across the middle of the screen when the player * crosses a threshold. Five kinds, each with its own weight: * * - location: you entered a named place. The largest title, glowing (cool for a sanctum, warm for a * region), with an optional subline between two scroll ornaments (its keeper, its trial). * - landmark: you found a point of interest. Plain cream, no glow, under a small kicker naming the// Copyright (c) 2026 Bruno Delorence. MIT License./* * The title cards' art: the small ornaments either side of a kicker or a subline, and the quest * kinds' icons. All are flat SVG in currentColor (or their own fixed colours, for the icons), drawn * on small grids and sized in em by the stylesheet. All are decorative: aria-hidden. *//** A quill-like leaf, pointing inwards at the text: a tapered blade with a hooked tail. */export function LeafDash() { return ( <svg viewBox="0 0 22 8" aria-hidden="true" focusable="false"> <path d="M1 5.1C4.4 2.6 9.8 1.4 21 3.6 13.6 3.8 9.2 4.6 5.6 6.2 4.4 6.7 3.4 6.4 3.6 5.6 3.8 5 4.6 4.8 5.2 5.1Z" fill="currentColor" /> </svg> );}/** A scroll end: a ring and a bead on the outside, then a stem tapering towards the text. */export function ScrollEnd() { return ( <svg viewBox="0 0 34 12" aria-hidden="true" focusable="false">/* Title cards (title-cards.tsx): one card at a time, centred on its own dark plate, with a row of chips below. Every size of a card is in em of the plate's font size (the location title's size: 60 px on a wide stage, down to 26 px on a phone, or --title-size), so a card scales as one. A game draws its titles straight over the world; here there is no world, so the card sits on a plate of warm dark grey, lit a little at its centre, and its colours (cream, a cool or warm glow, a boss's white and red, the quest's pale band) stay the same on a light page or a dark one. Only the plate's edge, its shadow and the chips follow the colour scheme, through light-dark() tokens. Glows are text- and box-shadows, layers behind the sharp letters; no filter touches them. Motion: each part of a card plays keyframes as it mounts, offset by --wait when a card is still leaving. Titles gather (letter-spacing and text-indent shrink together, so the line stays centred) as they fade in, then their glow swells. */.root { /* The titles. */ --cream: #e9e1d1; --white: #f4fafa; --cool-edge: #5b7989; --cool-glow: rgb(150 205 238 / 0.95); --cool-glow-wide: rgb(120 190 235 / 0.5); --warm-glow: rgb(255 244 214 / 0.62); --warm-glow-wide: rgb(255 236 200 / 0.32); --boss-edge: #760808;// 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(- The titles, kickers and labels are real text; the ornaments, the enemy’s rule and the quest icons are aria-hidden.
- A polite live region reads each new card (“Mire Stalker. Brackwood Marsh.”) and a completed quest (“Main Quest complete: The Drowned Archive.”). A leaving card is hidden from assistive technology at once.
- The chips are toggle buttons (aria-pressed) in a labelled group, reachable by Tab, with a :focus-visible ring; pressing the shown card’s chip replays it.
- Small text never goes under 11 px, and every colour pair keeps its contrast on the plate, whatever the page’s colour scheme.
- Reduced motion: no gathering, slams, shakes, pops, draws or drops; each part only fades in for 200 ms. The demo never plays.