GlyphKit
@mythxengine/glyphkit — a 16-bit pixel-art React design system
GlyphKit is a dark-mode-first, genre-aware design system built for retro and TTRPG-style applications. Themes are generated as CSS custom properties at runtime, so swapping between the 8 built-in presets (Fantasy, Sci-Fi, Paranoia, Horror, and Dungeon aesthetics) is a single hook call — no static CSS bundle, no FOUC.
The top-level entry is motion-free: components render static DOM unless you opt in to animation. The optional @mythxengine/glyphkit/motion subpath ships GlyphMotionProvider (which switches the core components to pixel-perfect Framer Motion animation) plus standalone motion wrappers with step-based easing that matches steps(N, end) timing.
Installation
pnpm add @mythxengine/glyphkit
npm install @mythxengine/glyphkit
Peer dependencies:
react | ^18.0.0 || ^19.0.0 | yes |
react-dom | ^18.0.0 || ^19.0.0 | yes |
framer-motion | ^11.0.0 || ^12.0.0 | optional — only if you import from @mythxengine/glyphkit/motion (incl. GlyphMotionProvider) |
The top-level import never loads framer-motion — theming, components, and icons work without it installed.
Quick Start
import { ThemeProvider, themeRegistry, useTheme } from "@mythxengine/glyphkit";
function App() {
return (
<ThemeProvider themes={themeRegistry} defaultTheme="kingdom-quest">
<YourApp />
</ThemeProvider>
);
}
function YourApp() {
const { theme, setTheme } = useTheme();
return (
<div>
<h1 style={{ color: "var(--color-primary-500)" }}>{theme.displayName}</h1>
<button onClick={() => setTheme("neon-terminal")}>Switch to Sci-Fi</button>
</div>
);
}
ThemeProvider injects the active theme's CSS custom properties on <html> and listens for prefers-color-scheme changes.
For the pixel-utility classes (.border-pixel*, .shadow-pixel-*, .font-pixel, .effect-scanlines, etc.), the stepped animation utilities (.animate-pixel-pulse/bounce/blink/spin/fade-in/pop), the screen transitions (.transition-pixel-scanline-in, -flash-*, -wipe-*, -pixelate-*, -glitch-*), plus reduced-motion / focus-visible defaults, import the baseline stylesheet once at app entry:
import "@mythxengine/glyphkit/styles";
The stylesheet is theme-neutral — ThemeProvider will overwrite the first-paint defaults at runtime.
Subpath exports
@mythxengine/glyphkit | Theme provider, registry, generator, components, transitions, icons — motion-free |
@mythxengine/glyphkit/motion | GlyphMotionProvider + Framer Motion wrappers (PixelButton, PixelCard, hooks, …) |
@mythxengine/glyphkit/icons | Theme-aware icon registry built on pixelarticons |
@mythxengine/glyphkit/styles | Baseline stylesheet: pixel utility classes, effects, a11y defaults (CSS) |
Theme Presets
deadband-grim | Deadband — Doomed Seas | Horror | Rust (#c8804a) | Lantern Gold (#e0a020) |
cassette-retro | Deadband Terminal | Sci-Fi | Oxide Amber (#d35600) | CRT Amber (#f2a900) |
cassette-phosphor | Deadband Phosphor | Sci-Fi | Phosphor Green (#33ff66) | Lime (#aaff33) |
kingdom-quest | Kingdom Quest | Fantasy | Purple (#9686ab) | Gold (#d4a72c) |
neon-terminal | Neon Terminal | Sci-Fi | Cyan (#00d4ff) | Magenta (#ff00ff) |
alpha-complex | Alpha Complex | Paranoia | Red (#cc0000) | Yellow (#ffcc00) |
shadow-realm | Shadow Realm | Horror | Crimson (#8b2942) | Sickly Green (#4a8b2a) |
deep-dungeon | Deep Dungeon | Dungeon | Stone Gray (#4a4a58) | Torchlight (#e07020) |
API
<ThemeProvider />
<ThemeProvider
themes={themeRegistry}
defaultTheme="kingdom-quest"
defaultColorScheme="dark"
storageKey="glyphkit-theme"
>
{children}
</ThemeProvider>
useTheme()
const {
theme,
themeId,
colorScheme,
setTheme,
setColorScheme,
transition,
setTransition,
isLoaded,
} = useTheme();
Pre-built UI
import { ThemeSelector, ThemeToggle } from "@mythxengine/glyphkit";
<ThemeSelector />
<ThemeSelector compact />
<ThemeToggle />
Component set
All exported from the top level (motion-free):
- Surfaces & structure —
Frame, Card, Dialog, AlertDialog / ConfirmDialog, Drawer, Popover, Tooltip, Separator, ScrollArea, EmptyState
- Disclosure —
Collapsible / CollapsibleContent (stepped grid-rows height animation, framer-free), Accordion (single/multiple, composed from Collapsible)
- Actions & menus —
Button, Toggle, ToggleGroup, DropdownMenu, ContextMenu (right-click at pointer, viewport-clamped), Menubar (APG menubar keyboard pattern) — all three menus share the DropdownMenuItem[] model (items, separators, disabled, danger). ⚠️ ContextMenu positions with position: fixed + viewport coordinates, so it must not sit inside a transformed/filtered ancestor (CSS transform, filter, will-change, … make that ancestor the containing block — this includes framer-motion elements mid-animation); see the component JSDoc.
- Forms —
Input, Textarea, Label, Select, Combobox (APG editable combobox: typeahead-filtered listbox popup, groups, loading + empty states), PixelCheckbox, RadioGroup, Switch, PixelSlider
- Command palette —
Command (inline ⌘K-style palette: filter input over grouped results, match highlighting, Kbd shortcuts) and CommandDialog (the same palette composed inside Dialog) — see Combobox & Command
- Status & data —
Badge, Chip, Alert, Progress (determinate / indeterminate / pixel-segment), Spinner, Skeleton, Stat, Avatar / AvatarGroup, Kbd
- Feedback —
PixelToast (standalone), ToastProvider + useToast + Toaster (FIFO queue — see Toasts)
- Navigation —
PixelTabs, Breadcrumb (nav landmark, collapsed-middle overflow), Pagination (ellipsis ranges, compact variant)
- Icons & theming —
Icon, ThemeSelector, ThemeToggle
- Domain (TTRPG) —
TypewriterText, SegmentClock, OutcomeBadge, StakesChips, GrainVignette — see Domain primitives (TTRPG)
Interactive primitives follow the WAI-ARIA authoring patterns (roving
tabindex + arrow keys on RadioGroup/ToggleGroup/PixelTabs and across
Accordion headers / Menubar items / menu items, role="switch" on
Switch, aria-pressed on Toggle, keyboard-operable dismiss on
Chip/Alert, focus trap + focus return on AlertDialog (and opt-in on
Dialog via trapFocus — one shared trap stack, so stacked modals
compose; CommandDialog uses it),
aria-current="page" on Breadcrumb/Pagination, and the editable
combobox pattern on Combobox/Command — aria-activedescendant
visual cursor, DOM focus stays in the input).
Toasts
import { ToastProvider, Toaster, useToast } from "@mythxengine/glyphkit";
<ToastProvider maxVisible={3} duration={5000}>
<App />
<Toaster placement="bottom-right" />
</ToastProvider>;
const { toast, update, dismiss } = useToast();
const id = toast({ title: "SAVING…", variant: "loading" });
update(id, { title: "SAVED", variant: "success", duration: 3000 });
Variants: info / success / warning / error (assertive role="alert") /
loading (persistent, blinking indicator). The queue is FIFO with at most
maxVisible on screen; hovering or focusing the viewport pauses every
visible countdown. Toasts render the PixelToast visuals — which also
remain available standalone (variant and inline props are additive).
Combobox & Command
Both ride one internal listbox core (APG editable-combobox pattern:
role="combobox" input, aria-activedescendant visual cursor with
wrap-around arrows + disabled-skip, DOM focus never leaves the input;
case-insensitive substring filtering by default, filter prop to
customize; "NO SIGNAL" pixel empty state).
import { Combobox, Command, CommandDialog } from "@mythxengine/glyphkit";
<Combobox
aria-label="Weapon"
options={[
{ value: "sword", label: "Rust Sword", group: "Melee" },
{ value: "bow", label: "Short Bow", group: "Ranged", disabled: true },
]}
onValueChange={setWeapon} // controlled via `value`, uncontrolled via `defaultValue`
loading={isFetching} // popup shows a Spinner
/>;
<Command
aria-label="Actions"
items={[{ label: "Roll dice", onSelect: roll, group: "Play", shortcut: "R" }]}
/>;
<CommandDialog open={paletteOpen} onClose={closePalette} items={items} />;
Escape unwinds one layer per press (topmost consumes exclusively):
Combobox closes the popup, then clears; CommandDialog clears an active
filter first, then closes the dialog. The inline surfaces never trap
Tab — each is a single tab stop, and Tab (or any focus departure) closes
the Combobox popup and reverts an abandoned draft to the selected
option's label (value untouched, no onValueChange; Escape instead keeps
the draft when closing, per the APG close-then-clear ladder).
CommandDialog is modal and does trap Tab: it sets Dialog's opt-in
trapFocus prop (default false; shares the AlertDialog trap stack so
stacked modals compose) and returns focus to the opener on close.
Positioning is the Popover model (no portal — string placement, popup
rendered next to the input), so the same containing-block caveats apply.
CSS Variables
Every preset emits a consistent token surface:
--color-primary-500;
--color-accent-500;
--background-primary;
--foreground-primary;
--surface-primary;
--grid-unit: 8px;
--border-thin: 1px;
--border-medium: 2px;
--border-thick: 4px;
--shadow-inset;
--shadow-raised;
--shadow-glow;
--outcome-critical-success;
--outcome-success;
--outcome-partial;
--outcome-failure;
--outcome-critical-failure;
--font-pixel: "Press Start 2P", monospace;
--font-terminal: "VT323", monospace;
--timing-function: steps(4, end);
--duration-fast: 100ms;
--duration-normal: 200ms;
--duration-slow: 400ms;
Outcome-tier tokens are emitted for every preset: explicit per-preset values via colors.<scheme>.outcome (a partial block — pinned tiers win, unpinned tiers derive), otherwise derived from the preset's status colors (crit-gold ← warning, success ← success, partial ← warning, failure/crit-failure ← error). Built-in presets pin the tiers whose derived color fell below 3:1 contrast on the 20%-mix badge background (guarded by unit tests). resolveOutcomeColors(colors) exposes the resolved palette programmatically.
Programmatic CSS
import { generateFullStylesheet, generateCSSVariables } from "@mythxengine/glyphkit";
const css = generateFullStylesheet(theme, "dark");
const vars = generateCSSVariables(theme, "dark");
Domain primitives (TTRPG)
Higher-level primitives for tabletop play surfaces, ported from the MythX frame loop:
import {
TypewriterText, useTypewriter,
SegmentClock,
OutcomeBadge, OUTCOME_TIERS,
StakesChips,
GrainVignette,
useReducedMotion,
} from "@mythxengine/glyphkit";
<TypewriterText text={narration} onDone={showActions} />
<SegmentClock label="The Ritual Completes" filled={4} total={6} mode="countdown" />
<OutcomeBadge outcome="partial" />
<StakesChips stakes={{ position: "risky", effect: "standard", ability: "WIT", difficulty: "hard" }} />
<GrainVignette grain={0.06} vignette={0.55} />
OutcomeBadge colors read the --outcome-* tokens; OUTCOME_TIERS exposes each tier's label, short label, banner label, icon id, css var, and mount animation so consumers can compose their own tier surfaces.
Motion
The motion-free core (default)
The top-level @mythxengine/glyphkit chunk never imports framer-motion — its whole module graph is motion-free (a build-time test enforces this). Components that support motion (Button, Card, Dialog, Drawer, Icon, PixelToast) render static DOM by default. The motion prop and animation passthrough props (whileHover, transition, …) are still accepted for API compatibility; without a motion runtime they're simply stripped before reaching the DOM.
This means styles-only / theming-only consumers don't need framer-motion installed at all.
Turning animation on
Install the optional peer and mount GlyphMotionProvider once near your app root:
pnpm add framer-motion
import { ThemeProvider, themeRegistry, Button } from "@mythxengine/glyphkit";
import { GlyphMotionProvider } from "@mythxengine/glyphkit/motion";
<ThemeProvider themes={themeRegistry}>
<GlyphMotionProvider>
{/* every GlyphKit component below now animates with its retro presets */}
<Button variant="pixel">START</Button>
</GlyphMotionProvider>
</ThemeProvider>;
No prop changes needed — the provider injects a framer-motion-backed runtime through context and the same components switch to their animated rendering path (prefers-reduced-motion still wins). Advanced consumers can supply a custom runtime via MotionRuntimeProvider from the top-level entry.
Motion wrappers
The /motion subpath also ships the standalone animation-only wrappers:
import { PixelMotion, PixelButton, pixelEasing } from "@mythxengine/glyphkit/motion";
<PixelButton variant="pixel" onClick={onClick}>Press Start</PixelButton>
<PixelMotion
as="div"
steps={4}
duration="fast"
pixelSnap
whileHover={{ y: -2 }}
whileTap={{ y: 2 }}
>
Hover me
</PixelMotion>
instant | 1 | 0ms | Snap |
retro2 | 2 | 100ms | NES-style |
retro4 | 4 | 200ms | SNES-style (default) |
retro8 | 8 | 400ms | Smoother retro |
Components: PixelMotion, PixelButton, PixelCard, PixelStatusBar, PixelDialog, PixelIcon.
Hooks: usePixelAnimation, usePixelSpring, useReducedMotion.
All motion respects prefers-reduced-motion.
Escape hatch — when GlyphKit motion isn't enough
The motion module is opinionated about step-based easing (steps(N, end)) because that's what makes the aesthetic work. For animations that need genuinely smooth easing (bezier curves, spring physics that mid-flight, page-turn transitions, etc.), import framer-motion directly:
import { motion, AnimatePresence } from "framer-motion";
<motion.div animate={{ x: 100 }} transition={{ duration: 0.4, ease: [0.2, 0.8, 0.2, 1] }} />;
The genre/variants helpers (buttonVariants, cardVariantPresets, etc.) compose with raw framer-motion — pass them into your own motion.X and you keep the look without going through PixelMotion.
Motion variant state vocabulary
Each variant family has its own lifecycle state names. Stick to these when composing custom motion.X elements with a preset:
buttonVariantPresets | initial, hover, tap, disabled, pulse (neon only) |
cardVariantPresets | initial, hover, tap, selected, hidden, visible, enter, exit |
dialogVariantPresets | hidden, visible, exit (note: no initial) |
statusBarVariantPresets | complete, countdown, counting, critical, damage, … |
cursorVariantPresets | still, visible, blink, bob, pulse, spin, selected |
| genre presets | each variant exposes its own (chestOpen, hologram, bloodDrip, …) |
Icons
import { Icon } from "@mythxengine/glyphkit";
import { getIconIdsByCategory, registerIcons } from "@mythxengine/glyphkit/icons";
<Icon name="ui-close" />
<Icon name="condition-poisoned" size="lg" animation="pulse" />
The built-in catalogs (UI / condition / genre) register lazily on first registry read — there is no side-effect import to keep alive, so the icon modules are safe under aggressive tree-shaking (the package's "sideEffects": ["**/*.css"] only shields stylesheet imports; every JS module remains tree-shakeable). Custom icons registered via registerIcon / registerIcons always win over the defaults, regardless of ordering. If you want eager registration (SSR warmup, tests), call registerDefaultIcons().
Customising the Registry
import { createRegistry, themeRegistry, ThemeProvider } from "@mythxengine/glyphkit";
const custom = createRegistry({
"kingdom-quest": {
...themeRegistry["kingdom-quest"],
colors: {
...themeRegistry["kingdom-quest"].colors,
dark: {
...themeRegistry["kingdom-quest"].colors.dark,
primary: { 500: "#ff0000" },
},
},
},
});
<ThemeProvider themes={custom}>...</ThemeProvider>;
Next.js (App Router)
Wrap the provider in a client component:
"use client";
import { ThemeProvider, themeRegistry } from "@mythxengine/glyphkit";
export function Providers({ children }: { children: React.ReactNode }) {
return <ThemeProvider themes={themeRegistry}>{children}</ThemeProvider>;
}
The motion subpath requires the same "use client" boundary because Framer Motion is client-only.
Contributing: adding a component
New components live in src/components/<Name>.tsx and follow a settled shape — copy an existing sibling (e.g. Frame.tsx for static, Button.tsx for motion-capable) rather than inventing structure:
- Styling — variants via
cva from class-variance-authority; every color/size/typography value reads a theme CSS var with a fallback (text-[length:var(--typo-body,14px)], bg-[var(--primary)], border-[var(--foreground-muted)]). No hardcoded palette values, no border-radius (this is a pixel system — rounded-none). Reuse the utility classes from @mythxengine/glyphkit/styles (border-pixel*, shadow-pixel-*, font-pixel).
- Motion (only if the component animates) — never import framer-motion in
src/components/. Read the injected runtime via useMotionRuntime() from ../motion-runtime/index.js and branch: static DOM when null (strip passthrough props with stripMotionProps), runtime.motion.* elements when present. Preset data comes from src/motion/variants/* (type-only framer imports there are fine). If you expose a motion prop, declare the preset union as a literal list (see BUTTON_MOTION_PRESETS) and add it to src/__tests__/motion-presets-sync.test.ts.
- CSS generator — only touch
src/generator/css-generator.ts if the component needs a new global utility class or keyframe (like the skeleton pulse). Component-local styling belongs in the component's cva classes.
- Exports — named export (+
Props type) from the component file, re-export through src/components/index.ts. Never add a default export; never make the module side-effectful ("sideEffects" lists only **/*.css — JS modules must stay pure so they remain tree-shakeable).
- Stories — add
stories/3-Primitives/<Name>.stories.tsx in the monorepo root with title: "3 Primitives/<Name>". Cover each visual variant, and give at least the primary stories a play function (storybook/test: within(canvasElement) + userEvent + expect) asserting render and core interaction. pnpm test-storybook runs them headless.
- Unit tests — pure logic (class output, registry behavior, SSR rendering via
react-dom/server) goes in src/__tests__/*.test.ts(x); pnpm --filter @mythxengine/glyphkit test must stay green, including the motion-free dist-graph test.
- Docs + changeset — update the README (component mention, any new preset/util) and add a changeset (
pnpm changeset).
Versioning & Releases
GlyphKit follows Semantic Versioning. Releases are managed with Changesets; see the monorepo .changeset/ directory for in-flight changes.
License
MIT © Josh Mabry / protoLabs. See LICENSE.