Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Quest log

New

A game’s adventure log: quest rows by type and state, a cream selection frame that glides between them, and the quest’s story beside.

quest-log/quest-log.tsx
Live · interactive

Quest Log

  • The Quiet HighlandWindward Ridge
  • Follow the Glimmer MapAshen Steps
  • Wake the Four BellsBellwater Vale
  • Light at the Old PierSaltmarsh
  • The Sealed ArchiveLocked

The Quiet Highland

Lantern KeeperWindward Ridge

After your climb down from the watchtower, the keeper told you that the valley below fell silent a hundred years ago, the night the Ashen Tide rolled in.

A bell is still ringing somewhere past the ridge, but you cannot reach it unless you leave the highland on the old glider.

The keeper will trade the glider for whatever lies sealed in the ruins nearby...

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

  • A pause-menu page where the player reviews the quests they picked up and picks the one to follow on the map.
  • Tasks sorted into a few kinds (main, trials, side, memories) that the player pages through with the shoulders or Q and E.
  • A list read with a controller: one frame moved by up and down, A to act, B to go back, with button hints at the foot.
  • Entries that change state over the game: new until looked at, tracked as the destination, dimmed once done, padlocked until open.
  • A story beside each entry, with the names that matter picked out, so the log doubles as a recap of the plot so far.

When not to use

  • A to-do list or task tracker in an app. Use a checklist with checkboxes and plain rows.
  • A short objective shown while playing. Use a HUD toast or an objective line in the corner; this is a full-screen page.
  • Choosing one of a few answers in a conversation. Use a choice list beside the dialogue box.
  • Items with counts, stats or art to compare. Use an item grid, which shows each piece as a tile.
  • Navigation between the pages of a pause menu. Use menu tabs or a system menu; the tabs here only sort quests.

You can add this component with the shadcn CLI or by hand:

Into components/quest-log of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/quest-log.json

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

journal.tsx
import { QuestLog, type Quest } from './quest-log';const QUESTS: Quest[] = [  {    id: 'bells',    type: 'main',    title: 'Wake the Four Bells',    location: 'Bellwater Vale',    giver: 'The Tollwoman',    body: ['Ring all four to break the [Long Hush] and open the road north.'],  },  { id: 'goat', type: 'side', title: 'The Wandering Goat', location: 'Fennick Mill', state: 'new' },  { id: 'archive', type: 'main', title: 'The Sealed Archive', location: 'Unknown', state: 'locked' },];export function Journal({ onClose }: { onClose: () => void }) {  return (    <QuestLog      quests={QUESTS}      autoplay={false}      defaultTracked="bells"      onTrack={(quest) => console.log('Destination', quest?.title)}      onBack={onClose}    />  );}

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 pause-menu page where the player reviews the quests they picked up and chooses the one to follow. Icon tabs over a track sort them into main quests, trials, side quests and memories; each row carries its type’s glyph, the title and the location in the type’s colour, a breathing dot while it is new, a tab while it is the destination, dimmed once completed, a padlock while locked. The selection is one cream frame that glides from row to row, and the panel beside it tells the quest’s story with the names that matter in red. Use the arrow keys, A to set the destination, or click.

  • Quests are a flat list, each with a type (main, trial, side, memory) and a state (default, new, completed, locked). The tabs filter it by type; each category remembers its own selected row, so going back to a category lands where the player left it.
  • The selection is a single frame laid over the list and moved by translate (190 ms, an overshoot curve that passes the row by about 1.5 px and settles), so it glides instead of blinking from row to row; its notched cream corners are four small gradient triangles on a pseudo-element, above the inset glow.
  • Changing category slides the shoulder marker and the bright part of the track to the new tab and moves the category name over it (260 ms); the list is keyed by category, so its rows remount and come in from the side of travel, 32 ms apart.
  • The detail panel is keyed by quest: each change replays a 240 ms fade with an 8 px slide from the direction of travel (sideways for a category change). Words written in [brackets] in a paragraph are picked out in red.
  • A new quest’s dot is a ringed radial gradient whose box-shadow breathes every 1.8 s. Once the row has stayed selected for 700 ms it is seen: the dot swells to 1.45 and vanishes, and onSeen is called. Passing over it quickly does not count.
  • Setting the destination slides the grey tab out of the row’s right end with a small overshoot and a flash, while the previously tracked row pulls its tab back in; a new dot steps aside for it. On a completed or locked quest the frame shakes instead.
  • Every size is a multiple of --u, 1 px when the plate has its full 932 px and less on a narrower stage (min(1px, 100cqi / 932)); under 700 px a container query moves the panel under the list and lets --u follow the list’s width. The plate, rows and panel stay dark on any page, as a game draws them; only the plate’s edge and shadow follow the colour scheme.
  • A demo opens the log, reads a new quest, sets a destination, tries a completed one, and pages through the categories while the log is on screen and untouched; any input hands it to the reader.
PropTypeDefaultDescription
questsQuest[]sixteen sample questsThe quests, in order: { id, type, title, location, giver?, body?, state? }. type is main, trial, side or memory; state is default, new, completed or locked; body is one string per paragraph, with [words] picked out in red.
categoriesQuestCategory[]Main Quests, Trials, Side Quests, MemoriesThe tabs, in order, as { type, label }.
headingstring'Quest Log'The page’s title, over the log.
defaultCategory'main' | 'trial' | 'side' | 'memory'—The category it opens on (the first tab if not given).
defaultSelectedstring—The id of the quest selected first; its category opens.
defaultTrackedstring | null'quiet-highland'The id of the quest whose destination is set at first, or null for none.
onSelect(quest: Quest) => void—Called when the selection moves to a quest, or a category opens on one.
onTrack(quest: Quest | null) => void—Called when the destination is set on a quest, or removed (null).
onSeen(quest: Quest) => void—Called when a new quest has stayed selected for 700 ms and loses its dot.
onBack() => void—Called on B, Escape or the Back hint.
autoplaybooleantrueWhile on screen and untouched, a short visit to the log: rows read, a destination set, categories paged.
colorScheme'light' | 'dark' | 'system'—Forces light or dark for the plate’s edge and shadow and the focus ring; by default it follows the page. The log itself stays dark.
classNamestring—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.

quest-log.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useCallback,  useEffect,  useEffectEvent,  useId,  useMemo,  useRef,  useState,  type CSSProperties,  type KeyboardEvent,} from 'react';import { FaceGlyph, GiverMark, TrackMarker, TypeGlyph } from './glyphs';import {  DEFAULT_CATEGORIES,  DEFAULT_QUESTS,  type Quest,  type QuestCategory,  type QuestState,  type QuestType,} from './quests';
quests.ts
// Copyright (c) 2026 Bruno Delorence. MIT License.import type { QuestType } from './glyphs';export type { QuestType };/** How a quest stands: new (not yet looked at), completed (dimmed), locked (not open yet). */export type QuestState = 'default' | 'new' | 'completed' | 'locked';export type Quest = {  id: string;  type: QuestType;  title: string;  /** Where it happens, under the title, in the type's colour. */  location: string;  /** Who gave it, beside a red mark in the detail panel. */  giver?: string;  /** The story so far, one string per paragraph. Words in [brackets] are picked out in red. */  body?: string[];  state?: QuestState;};export type QuestCategory = { type: QuestType; label: string };
glyphs.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License./* * The quest log's art, small SVGs drawn in px of a full-size row (the CSS sizes each one, so they * scale with the log). One light fill, `currentColor`, so a row can dim or a tab can grey them; * the dark cut-outs use --ink. * * - Main: a winged emblem, a kite-shaped gem between two wings of three swept feathers. * - Trial: a diamond stone with a dark diamond inside and an arched doorway at its heart. * - Side: a speech bubble with three dots, its tail at the lower left. * - Memory: a keepsake picture, a card with a sun over two hills. * - Locked: a padlock, for a quest that does not open yet. * - The face buttons for the hints (A, B), and the shoulder marker that rides the category track. */export type QuestType = 'main' | 'trial' | 'side' | 'memory';export function TypeGlyph({ type }: { type: QuestType | 'locked' }) {  if (type === 'main')    return (      <svg viewBox="0 0 40 28" aria-hidden="true">        <g fill="currentColor">          <path d="M20 1.6 L24.8 12 L20 22.4 L15.2 12 Z" />          {[1, -1].map((side) => (
quest-log.module.css
/* Quest log (quest-log.tsx): a game's adventure-log page on its own dark plate, centred on a   plain stage. Every size is a multiple of --u, 1 px at full size (a 932 px plate), less on a   narrower stage; under 700 px the detail panel moves under the list and --u follows the list's   width instead.   The plate, rows and panel are dark on any page, as a game draws them over its world; only the   plate's edge and its shadow on the page follow the colour scheme, through light-dark() tokens.   No filter touches a glyph or a word: glows are box- and text-shadows behind the sharp art.   Motion: the selection frame and the category marker move by transitions on translate; rows,   the panel's content, the category name, the new dot and the refusal shake are keyframes. */.root {  /* Text and glyphs on the plate. */  --text: #e2ded3;  --text-dim: #aaa79f;  --glyph: #e2ded3;  --glyph-off: #85837a;  --ink: #141413;  /* The plate, its hairline frame, its edge and its shadow on the page. */  --plate: #1a1b17;  --plate-deep: #121310;  --plate-edge: rgb(255 255 255 / 0.06);  --shade: rgb(20 18 12 / 0.24);
use-autoplay.ts
// 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 categories are a tablist of buttons named by their label, with a roving tab stop; the rows are a listbox in the tab panel, whose options are named by title, location and state (“Follow the Glimmer Map, Ashen Steps, new”) and followed with aria-activedescendant.
  • Keyboard as a controller: up and down (arrows, W, S, Home, End) move the selection, left and right (arrows, Q, E, L, R) change category, A, Enter or Space set or remove the destination, B or Escape go back. The Back and Set Destination hints are buttons with aria-keyshortcuts.
  • A polite live region says the category and its count when it changes, the destination set or removed, and why a completed or locked quest refuses.
  • The detail panel is an article labelled by the quest’s title; the glyphs, the frame, the track and the dots are aria-hidden.
  • Everything shows a :focus-visible ring, drawn around the whole list for the listbox.
  • Reduced motion: the frame and the marker jump, rows appear without sliding, the panel only crossfades, the dot does not breathe and goes at once, and nothing shakes. The demo never plays.