Loyalty Patternsv1.00

Theming & tokens

Components read only --lk-* CSS custom properties. There is no theme provider, no config file, no class-name convention and no second styling system. If you can set a CSS variable, you can theme this kit.

The full token list is generated from registry/tokens.css further down this page, so it cannot drift from the source.

Why custom properties

Because they are the only theming mechanism every React ecosystem already agrees on. A design-system-specific API would force a choice between MUI, shadcn, Chakra, Base UI and plain CSS. Custom properties cascade, respect the platform, work in shadow DOM, need no JavaScript, and can be set inline for a single campaign.

The practical payoff is that adopting the kit into an existing design system is a mapping file, not a migration:

css
/* Bridge your design system onto the kit. ~20 lines. */
:root {
  --lk-surface: var(--md-sys-color-surface);
  --lk-text: var(--md-sys-color-on-surface);
  --lk-border: var(--md-sys-color-outline-variant);
  --lk-brand: var(--md-sys-color-primary);
  --lk-on-brand: var(--md-sys-color-on-primary);
  --lk-radius: var(--md-sys-shape-corner-medium);
  --lk-font-body: var(--md-sys-typescale-body-medium-font);
}

Your tokens stay the source of truth. The kit becomes a consumer of them rather than a competing authority.

Two tiers

Tier 1 — semantic. Surfaces, text, brand, reward, status, shape, space, motion. These are what you set. Roughly thirty properties for the whole kit.

Tier 2 — component. Each component exposes its own properties for the things only it has, and every one falls back to a Tier-1 token:

css
.lk-scratch__frame {
  border-radius: var(--lk-scratch-radius, var(--lk-radius-lg));
}

Set nothing and you inherit the semantic layer. Set the component token and you override one thing without touching the rest. This is why there is no variant prop anywhere in the kit — a variant is a bundle of decisions someone else made, and a token is one decision you made.

Brand and reward are two colours

--lk-brand is chrome: buttons, links, focus rings. --lk-reward is the win: reveals, celebrations, prize values.

Keeping them separate is not decoration. A reward that looks like a button reads as an action, and a member who has just won should not have to work out whether the number is clickable. If you genuinely want one colour, point both at it — but that should be a decision, not a default.

Dark mode is a revamp, not an inversion

[data-lk-theme="dark"] restates more than surfaces, and skipping the rest is what makes a dark mode look like a filter:

  1. The action colour lifts, but only when it must. A mid-tone brand that sings on white can go muddy on near-black. The kit lifts it only when it falls below 3:1 against the dark surface, and only as far as it needs to. Blanket-lifting makes the two modes look like different brands.
  2. Materials get their own identity. The scratch foil is paper silver in light and graphite in dark. A light foil on a dark card reads as a hole punched in the page.
  3. Elevation strategy flips. Shadow does little against dark, so separation comes from a border raised in contrast, and the shadows that remain deepen rather than soften.

Motion is one number

Every duration in the kit is calc(<base> * var(--lk-motion)). Setting --lk-motion: 0 collapses all choreography to instant state changes — one property, kit-wide.

tokens.css already sets it to 0 under prefers-reduced-motion, so the correct behaviour is the default. This is also why the kit has no motion toggle: honouring the OS preference is not something an application should be able to override on a member's behalf.

Density and type

There is no spacing scale and no type scale, on purpose.

--lk-space is a single unit (4px) and components use calc() multiples of it, so one property retunes density everywhere. Type sizes are em against the host page and scale through --lk-text-scale, so components inherit your typography instead of asserting their own.

There are no breakpoints either. Components respond to their container, not the viewport, because a card in a 320px sidebar and a card in a full-bleed hero are the same component in different rooms.

Full token reference

Generated from registry/tokens.css. Anything with a dark value is restated in [data-lk-theme="dark"]; anything without inherits.

Color — surfaces & text

TokenLightDark
--lk-surface#ffffff#18181b
--lk-surface-raised#f7f7f8#232327
--lk-overlayrgb(0 0 0 / 0.5)rgb(0 0 0 / 0.72)
--lk-border#e4e4e7#35353b
--lk-text#18181b#fafafa
--lk-text-muted#71717a#a1a1aa

Color — brand & reward

TokenLightDark
--lk-brand#4f46e5#8b83f5
--lk-on-brand#ffffff#14131d
--lk-reward#f59e0b#fbbf24
--lk-on-reward#1c1917#1c1917

Color — status

TokenLightDark
--lk-success#16a34a#4ade80
--lk-warning#d97706#fbbf24
--lk-danger#dc2626#f87171

Action

TokenLightDark
--lk-actionvar(--lk-brand)inherits

Focus — themed, never removed

TokenLightDark
--lk-focusvar(--lk-brand)inherits
--lk-focus-width2pxinherits

State

TokenLightDark
--lk-disabled-opacity0.45inherits

Shape

TokenLightDark
--lk-radius-sm6pxinherits
--lk-radius12pxinherits
--lk-radius-lg20pxinherits
--lk-radius-round9999pxinherits

Space — the single density lever

TokenLightDark
--lk-space4pxinherits

Typography — no sizes

TokenLightDark
--lk-font-displaysystem-ui, sans-serifinherits
--lk-font-bodysystem-ui, sans-serifinherits
--lk-font-numericvar(--lk-font-body)inherits
--lk-text-scale1inherits

Elevation

TokenLightDark
--lk-shadow-raised0 1px 3px rgb(0 0 0 / 0.08), 0 4px 12px rgb(0 0 0 / 0.06)0 1px 2px rgb(0 0 0 / 0.6), 0 8px 24px rgb(0 0 0 / 0.45)
--lk-shadow-overlay0 8px 32px rgb(0 0 0 / 0.16)0 12px 48px rgb(0 0 0 / 0.6)

Motion

TokenLightDark
--lk-motion1inherits
--lk-easecubic-bezier(0.2, 0, 0, 1)inherits
--lk-ease-celebratecubic-bezier(0.34, 1.56, 0.64, 1)inherits

Component tokens — per-mode identity

TokenLightDark
--lk-scratch-foil#c7c7cf#3a3a42
--lk-scratch-foil-sheen#ebebf2#55555f
--lk-scratch-foil-text#6b6b74#b9b9c4

Copy a theme

The playground composes a theme and emits it as CSS for both modes. Paste it over your own tokens and delete what you do not want — the export is a starting point you own, not a file to keep in sync.