Customization

Override CSS custom properties at runtime and every component picks up the change. No build step in most cases.

Tokens

Every visual decision lives in a small set of CSS custom properties named with the --st-* prefix. Set one on :root to retheme the whole app. Scope the override to a wrapper class to retheme only that subtree. Components read their tokens via var(), so they pick up new values without a rebuild.

Values are authored in OKLCH because hover and active states derive at runtime via color-mix(in oklch, …). OKLCH mixing stays vivid through the midpoints, while sRGB mixing tends to muddy the brand colors you would actually pick. Override --st-primary and every state of every primary-toned component picks up the new hue automatically.

Every background-providing token has a paired -foreground. If you override one, override the other. The pairing keeps text contrast safe when a base hue changes.

Below is the full set as it ships. Copy it into your own stylesheet, change the values you want, and load it after the framework bundle. The source is src/scss/tokens/_theme.scss.

src/scss/tokens/_theme.scss
:root {
  /* Intent — 5 pairs. Intents stay put across themes; foregrounds flip per
     the pairing rule, except --st-warning-foreground which stays dark in
     both themes (yellow stays yellow, text on it stays dark). */
  --st-primary:             oklch(0.639 0.1844 257.69);
  --st-primary-foreground:  oklch(1 0 0);
  --st-success:             oklch(0.6966 0.1853 149.54);
  --st-success-foreground:  oklch(1 0 0);
  --st-warning:             oklch(0.7697 0.1645 70.61);
  --st-warning-foreground:  oklch(0.27 0 0);
  --st-danger:              oklch(0.6155 0.2229 26.82);
  --st-danger-foreground:   oklch(1 0 0);
  --st-info:                oklch(0.677589 0.148143 238.1044);
  --st-info-foreground:     oklch(1 0 0);

  /* Surface tier. Default neutrals are pure gray. To retint warm or cool,
     override these tokens in a parent scope (see Warm neutrals preset). */
  --st-background:          oklch(1    0 0);
  --st-foreground:          oklch(0.27 0 0);
  --st-surface:             oklch(1    0 0);
  --st-surface-2:           oklch(0.98 0 0);
  --st-surface-3:           oklch(0.97 0 0);
  --st-border:              oklch(0.93 0 0);
  --st-muted-foreground:    oklch(0.52 0 0);

  /* Interactional. neutral = rest fill for filled-neutral elements.
     accent  = hover bg over neutral or transparent surfaces.
     highlight = persistent selected / current bg (soft primary tint). */
  --st-neutral:             oklch(0.91 0 0);
  --st-neutral-foreground:  var(--st-foreground);
  --st-accent:              oklch(0.96 0 0);
  --st-accent-foreground:   var(--st-foreground);
  --st-highlight:           color-mix(in oklch, var(--st-primary) 12%, transparent);
  --st-highlight-foreground: var(--st-primary);

  /* Focus. Defaults to --st-primary so brand swaps repaint focus rings. */
  --st-ring:                var(--st-primary);

  /* Geometry. radius: 0 = brutalist, 0.75rem = default, 1rem = soft.
     density: 1 default, 0.875 compact, 1.125 comfortable. */
  --st-radius:              0.75rem;
  --st-shadow:              0 1px 3px rgba(0, 0, 0, 0.05),
                            0 1px 2px rgba(0, 0, 0, 0.03);
  --st-density:             1;

  /* Derived siblings (not customization knobs). Smaller / larger size
     variants reach for these so brutalist (--st-radius: 0) cascades to 0
     for every size; additive offsets would leave lg with a stray corner. */
  --st-radius-sm:           calc(var(--st-radius) * 0.667);
  --st-radius-lg:           calc(var(--st-radius) * 1.333);

  /* Type. */
  --st-font-sans:           "Inter", system-ui, sans-serif;
  --st-font-mono:           "JetBrains Mono", ui-monospace, monospace;
}

/* Dark mode. Intents stay; surface and interactional tiers flip. */
[data-theme="dark"],
.dark {
  --st-background:          oklch(0.155 0 0);
  --st-foreground:          oklch(0.97  0 0);
  --st-surface:             oklch(0.18  0 0);
  --st-surface-2:           oklch(0.21  0 0);
  --st-surface-3:           oklch(0.23  0 0);
  --st-border:              oklch(0.27  0 0);
  --st-muted-foreground:    oklch(0.72  0 0);
  --st-neutral:             oklch(0.27  0 0);
  --st-accent:              oklch(0.26  0 0);
}

The defaults are a starting point. OKLCH is convenient because color-mix(in oklch, …) interpolates perceptually, but your overrides can use any color representation (HSL, hex, sRGB). A well-balanced theme takes time, so don’t expect to land it on the first pass.

How to customize

Four ways, in order of effort. Pick the lowest one that does the job.

1. Override a root token

This covers most cases (brand color, radius, shadow, density, dark deltas). One block in your stylesheet, and every component retunes.

:root {
  --st-primary: oklch(0.58 0.22 285);
  --st-radius: 0.5rem;
  --st-density: 0.875;
}

2. Override a component-scoped variable

Every component exposes its own knobs that fall back to the global token. Set --btn-radius to give buttons a different radius without retuning cards. Each component page lists its own variables in a Customization section at the bottom.

:root {
  --btn-radius: 9999px;     /* pilled buttons, default radius everywhere else */
  --card-radius: 1rem;      /* softer cards, default radius for buttons */
}

3. Use a utility class

For one-off tweaks to an existing component. You might want a heading muted in one place, a button with more gap to its neighbor, or a card row that needs to be flex. See Utilities for the shipped set (text, layout, spacing, sizing).

Utilities are made for tweaking shapes you already have. They aren’t a way to build new ones. Stacking .d-flex .gap-3 .p-4 .rounded .border on a <div> to assemble something card-shaped is the wrong tool. Build a component instead; see Building new components below.

4. Edit the Sass source

For things that tokens and utilities can’t reach. Three common cases.

  • Bundle size. Drop component imports you don’t use from src/scss/bundles/stisla.scss and rebuild. Components are imported one per line; deleting any line removes its CSS.
  • Breakpoints. Media queries can’t read CSS variables. Edit the Sass map in src/scss/tokens/_breakpoints.scss and rebuild.
  • Spacing scale. The fixed utility step ramp lives in src/scss/utilities/_utilities.scss as $spacing-scale. Override via $utilities-config or fork the file. See Utilities.

When to formalize

Components feel complete by default. When the same adjustment keeps showing up, the change isn’t really a tweak any more. It’s a missing variant.

Two outcomes to expect.

  • Contextual. A .btn reads too tight inside a card header but works on its own. Handle it locally with a utility or a context-scoped override. Adding a new variant for a single place is overkill.
  • Meaningful. The same .btn needs to be smaller across tables, toolbars, and dense layouts. That’s a real variant. Refine the default or add a modifier (.btn--xs, .card--compact) in the component file.

The line between them is how often the adjustment shows up across contexts. A one-off difference at a single call site doesn’t qualify. Variants live in the component’s Sass file and cost nothing once you’re already in there.

Building new components

Before you reach for a new component, check if you can build it from parts you already have. A comment row is an avatar inside a list-group item with a title, timestamp, and description. A stats tile is a card with a heading and a number. Most “I need a new X” turns out to be card + list-group, card + form-control, or list-group + badge.

When composition won’t do it, write a real component. A few conventions to follow.

  • One Sass file per component under src/scss/components/. Name it after the block. _my-thing.scss defines .my-thing.
  • BEM naming. .block, .block__element, .block--modifier. Lowercase, hyphen-separated. Multiple modifiers stack flat on the root and never nest.
  • Read tokens via component-scoped fallback. border-radius: var(--my-thing-radius, var(--st-radius)). Users get a component knob and a global default for free.
  • Wrap padding in calc(N * var(--st-density)). Density retunes every component proportionally. Skip this, and your component opts out of the density knob.
  • States via color-mix(in oklch, …). No per-state tokens. Hover is color-mix(in oklch, var(--st-primary) 88%, black). The base hue is the only knob you set, and the states derive at runtime.
  • Runtime state hooks. [data-state="open|active"] for Radix-aligned concepts (open / closed, active, highlighted, orientation). .is-* classes for Stisla-original states (.is-loading, .is-collapsed). Pick one per concept and stick to it.
  • Every background var has a paired -foreground. Same rule as the root tokens.
  • Document the variables. Add a Customization section to your component’s demo page that tables the --my-thing-* variables (Variable / Default / Use). Look at Slider for the shape.

Here’s a .stat tile component that pulls every convention together.

src/scss/components/_stat.scss
.stat {
  // Component-scoped vars fall back to global tokens.
  --stat-radius:     var(--st-stat-radius, var(--st-radius));
  --stat-padding:    calc(1.25rem * var(--st-density));
  --stat-bg:         var(--st-surface);
  --stat-foreground: var(--st-foreground);
  --stat-border:     var(--st-border);

  display: block;
  padding: var(--stat-padding);
  background: var(--stat-bg);
  color: var(--stat-foreground);
  border: 1px solid var(--stat-border);
  border-radius: var(--stat-radius);
  text-decoration: none;
}

.stat__label {
  font-size: 0.8125rem;
  color: var(--st-muted-foreground);
}

.stat__value {
  margin-block-start: 0.25rem;
  font-size: 1.75rem;
  font-weight: 600;
}

// Intent modifier — paired bg + foreground derive from --st-primary at
// runtime, so a brand swap repaints this variant cleanly.
.stat--primary {
  --stat-bg:         color-mix(in oklch, var(--st-primary) 8%, var(--st-surface));
  --stat-foreground: var(--st-primary);
  --stat-border:     color-mix(in oklch, var(--st-primary) 25%, transparent);
}

// Interactive opt-in via element selector. Only a.stat / button.stat
// gets hover. A static <div class="stat"> stays inert.
a.stat:hover,
button.stat:hover {
  background: color-mix(in oklch, var(--stat-bg) 92%, var(--st-foreground));
}

// Stisla-original runtime state — the .is-* prefix signals JS-driven
// mutability, distinct from Radix-aligned [data-state] hooks.
.stat.is-loading {
  opacity: 0.55;
  pointer-events: none;
}
<a class="stat stat--primary" href="/orders">
  <span class="stat__label">Open orders</span>
  <span class="stat__value">128</span>
</a>

The component file ends up small, scoped, and easy to delete. The next person reading the codebase can see what the thing actually is from one file. Open any of the existing components under src/scss/components/ for a template.

Presets

Every demo below uses the same markup. Only the wrapper class changes. The sources live in src/site/styles/_customization-presets.scss. Copy a block, swap the wrapper class for :root to apply globally, or keep the wrapper to scope it to a subtree.

Default

Reference for comparison. No overrides.

Card title

A card with a header, body, and footer to show every padding and surface tier respond to the preset.

    <div class="demo-row demo-row--top demo-row--gap-md">
  <div class="card" style="width: 16rem;">
    <div class="card__header">Card title</div>
    <div class="card__body">
      <p class="card__text">A card with a header, body, and footer to show every padding and surface tier respond to the preset.</p>
      <div class="demo-row">
        <button type="button" class="btn btn--primary">Primary</button>
        <button type="button" class="btn btn--outline btn--neutral">Cancel</button>
      </div>
    </div>
    <div class="card__footer">
      <span class="fs-2 text-muted-foreground">Footer note</span>
    </div>
  </div>
  <div class="demo-stack">
    <div class="demo-row">
      <button type="button" class="btn btn--primary">Primary</button>
      <button type="button" class="btn btn--neutral">Neutral</button>
      <button type="button" class="btn btn--tertiary">Tertiary</button>
    </div>
    <div class="demo-row">
      <button type="button" class="btn btn--outline btn--primary">Outline</button>
      <button type="button" class="btn btn--ghost btn--primary">Ghost</button>
      <button type="button" class="btn btn--soft btn--primary">Soft</button>
    </div>
    <div class="demo-row">
      <button type="button" class="btn btn--primary btn--sm">Small</button>
      <button type="button" class="btn btn--primary">Default</button>
      <button type="button" class="btn btn--primary btn--lg">Large</button>
    </div>
  </div>
</div>

Brutalist

Radius zero and a solid offset shadow that tracks the foreground color. Two knobs reshape every surface. The shadow stays high-contrast in both themes because it resolves to --st-foreground, which flips per the dark block above.

Card title

A card with a header, body, and footer to show every padding and surface tier respond to the preset.

    <div class="preset-brutalist">
      <div class="demo-row demo-row--top demo-row--gap-md">
  <div class="card" style="width: 16rem;">
    <div class="card__header">Card title</div>
    <div class="card__body">
      <p class="card__text">A card with a header, body, and footer to show every padding and surface tier respond to the preset.</p>
      <div class="demo-row">
        <button type="button" class="btn btn--primary">Primary</button>
        <button type="button" class="btn btn--outline btn--neutral">Cancel</button>
      </div>
    </div>
    <div class="card__footer">
      <span class="fs-2 text-muted-foreground">Footer note</span>
    </div>
  </div>
  <div class="demo-stack">
    <div class="demo-row">
      <button type="button" class="btn btn--primary">Primary</button>
      <button type="button" class="btn btn--neutral">Neutral</button>
      <button type="button" class="btn btn--tertiary">Tertiary</button>
    </div>
    <div class="demo-row">
      <button type="button" class="btn btn--outline btn--primary">Outline</button>
      <button type="button" class="btn btn--ghost btn--primary">Ghost</button>
      <button type="button" class="btn btn--soft btn--primary">Soft</button>
    </div>
    <div class="demo-row">
      <button type="button" class="btn btn--primary btn--sm">Small</button>
      <button type="button" class="btn btn--primary">Default</button>
      <button type="button" class="btn btn--primary btn--lg">Large</button>
    </div>
  </div>
</div>
    </div>
_customization-presets.scss
.preset-brutalist {
  --st-radius: 0;
  --st-shadow: 4px 4px 0 var(--st-foreground);
}

Violet primary

Swap one token and every primary-toned surface picks up the new hue. Hover, active, focus, and the soft --st-highlight tint derive at runtime via color-mix. No extra overrides needed.

Card title

A card with a header, body, and footer to show every padding and surface tier respond to the preset.

    <div class="preset-violet">
      <div class="demo-row demo-row--top demo-row--gap-md">
  <div class="card" style="width: 16rem;">
    <div class="card__header">Card title</div>
    <div class="card__body">
      <p class="card__text">A card with a header, body, and footer to show every padding and surface tier respond to the preset.</p>
      <div class="demo-row">
        <button type="button" class="btn btn--primary">Primary</button>
        <button type="button" class="btn btn--outline btn--neutral">Cancel</button>
      </div>
    </div>
    <div class="card__footer">
      <span class="fs-2 text-muted-foreground">Footer note</span>
    </div>
  </div>
  <div class="demo-stack">
    <div class="demo-row">
      <button type="button" class="btn btn--primary">Primary</button>
      <button type="button" class="btn btn--neutral">Neutral</button>
      <button type="button" class="btn btn--tertiary">Tertiary</button>
    </div>
    <div class="demo-row">
      <button type="button" class="btn btn--outline btn--primary">Outline</button>
      <button type="button" class="btn btn--ghost btn--primary">Ghost</button>
      <button type="button" class="btn btn--soft btn--primary">Soft</button>
    </div>
    <div class="demo-row">
      <button type="button" class="btn btn--primary btn--sm">Small</button>
      <button type="button" class="btn btn--primary">Default</button>
      <button type="button" class="btn btn--primary btn--lg">Large</button>
    </div>
  </div>
</div>
    </div>
_customization-presets.scss
.preset-violet {
  --st-primary: oklch(0.58 0.22 285);
  --st-primary-foreground: oklch(1 0 0);
}

Dense

One knob. Every padding, gap, and height value wraps calc(X * var(--st-density)), so 0.875 shrinks the whole system proportionally with no per-component overrides.

Card title

A card with a header, body, and footer to show every padding and surface tier respond to the preset.

    <div class="preset-dense">
      <div class="demo-row demo-row--top demo-row--gap-md">
  <div class="card" style="width: 16rem;">
    <div class="card__header">Card title</div>
    <div class="card__body">
      <p class="card__text">A card with a header, body, and footer to show every padding and surface tier respond to the preset.</p>
      <div class="demo-row">
        <button type="button" class="btn btn--primary">Primary</button>
        <button type="button" class="btn btn--outline btn--neutral">Cancel</button>
      </div>
    </div>
    <div class="card__footer">
      <span class="fs-2 text-muted-foreground">Footer note</span>
    </div>
  </div>
  <div class="demo-stack">
    <div class="demo-row">
      <button type="button" class="btn btn--primary">Primary</button>
      <button type="button" class="btn btn--neutral">Neutral</button>
      <button type="button" class="btn btn--tertiary">Tertiary</button>
    </div>
    <div class="demo-row">
      <button type="button" class="btn btn--outline btn--primary">Outline</button>
      <button type="button" class="btn btn--ghost btn--primary">Ghost</button>
      <button type="button" class="btn btn--soft btn--primary">Soft</button>
    </div>
    <div class="demo-row">
      <button type="button" class="btn btn--primary btn--sm">Small</button>
      <button type="button" class="btn btn--primary">Default</button>
      <button type="button" class="btn btn--primary btn--lg">Large</button>
    </div>
  </div>
</div>
    </div>
_customization-presets.scss
.preset-dense {
  --st-density: 0.875;
}