Contributing

How the repo is laid out, how to run it locally, and how to add a component or a customization variable.

Repo layout

Three top-level source trees plus a small tooling folder.

  • src/scss. Sass source. tokens/ holds the --st-* surface and breakpoints. foundation/ holds normalize, reboot, grid, and containers. components/ is one file per BEM block. utilities/ is the utility layer. bundles/ contains the entry files that assemble the shipped stisla.css and stisla-full.css.
  • src/js. JavaScript runtime. core/ holds the Stisla.init() walker and shared classes. components/ is one file per behavior (Dialog, Dropdown, etc.). integrations/ holds à la carte add-ons like the carousel. index.js and index-full.js are the entry files for the two shipped JS bundles.
  • src/site. This docs site. layouts/ and partials/ are the Nunjucks chrome. pages/ is one file per route, including every component demo page. styles/site.scss and scripts/site.js hold site-only chrome that’s never bundled into the framework.
  • tools/. Vite plugin, build-time ToC injector, Shiki highlighter filter, static-site renderer.

Running locally

Node 20 or newer.

git clone https://github.com/stisla/stisla.git
cd stisla
npm install
npm run dev

The dev server runs Vite plus a Nunjucks watcher. SCSS rebuilds on save, HTML re-renders on save. npm run build compiles the bundles into site-dist/assets/ and renders every page into site-dist/.

Adding a component

Each component is one Sass file, one BEM block, and one demo page. The same pattern repeats across the 30-plus components already in the repo.

1. Write the Sass

Add src/scss/components/_my-thing.scss defining .my-thing. BEM names follow .block, .block__element, .block--modifier. Lowercase, hyphen-separated. Multiple modifiers stack flat on the root and never nest.

Read tokens via component-scoped fallback like border-radius: var(--my-thing-radius, var(--st-radius)). Users get a per-component knob and a global default for free.

Wrap padding and gap in calc(N * var(--st-density)). Without it, your component opts out of the density knob and feels foreign next to the rest.

Derive states with color-mix(in oklch, …), no per-state tokens. Hover is a runtime mix off the base hue, so if a user overrides the base, the hover follows.

Import the partial from src/scss/bundles/stisla.scss (or stisla-full.scss for integrations). One @use per line, so deleting any line removes that component from the bundle.

2. Write the behavior (if any)

If the component needs JavaScript, add src/js/components/MyThing.js exporting a class with a constructor that takes a root element, a .destroy() method, and DOM custom events for its lifecycle. Register it in src/js/core/init.js against the matching data-stisla-my-thing attribute so Stisla.init() picks it up.

For state hooks, use [data-state="open"] for Radix-aligned concepts (open / closed / active) and .is-loading or .is-* for Stisla-original states. The CSS reads from the attribute or class, and the JS writes it. Don’t mutate inline styles.

3. Write the demo page

Every component gets its own page. Add src/site/pages/my-thing.njk extending the base layout. Cover every variant and every state. Rest, hover (a sentence is fine, no animation needed), focus, active, disabled, and invalid where applicable.

Use the {% call ui.demo() %} macro for live previews. It renders the source twice. A live preview on top, and a Shiki-highlighted code block below. Snippets over five lines auto-collapse behind a View Code toggle.

The page structure mirrors the rest of the site. A <header> with <h1> and a short lead paragraph, then a <section> per topic with an <h2>. The build-time ToC walks h2 and h3 automatically. Pages with two or more h2s render a sticky ToC in the right rail.

End the page with a Customization section. See the next subsection.

4. Add it to the sidebar

Open src/site/partials/_site-sidebar.njk and add an entry under the matching group (Forms, Components, Overlays, Integrations, …). Keep groups alphabetical. The active-link highlight is automatic, so there’s nothing else to wire.

Adding a customization variable

Component-scoped variables open up a knob without forcing users into Sass. Pick a hyphenated name under the block prefix, like --btn-radius, --slider-thumb-w, or --card-padding.

Default to a global token via fallback when the knob is a global concept (radius, density, ring color, surface tier). Default to a literal when the knob is component-private (slider thumb width, badge font-size).

src/scss/components/_my-thing.scss
.my-thing {
  // Component-private default.
  --my-thing-thumb-w: 0.5rem;

  // Falls back to a global token, so overriding --st-radius retunes
  // this component too unless the user sets --my-thing-radius explicitly.
  --my-thing-radius: var(--st-radius);

  border-radius: var(--my-thing-radius);
}

Document every knob in the page’s Customization section. One row per variable, with columns for Variable, Default, and Use. The slider page is a good reference.

Variable Default Use
--my-thing-radius var(--st-radius) Corner radius
--my-thing-thumb-w 0.5rem Thumb width

For components with many knobs, group the table by purpose (sizing, surface, interaction). For components that mostly rely on shared tokens, a short pointer paragraph back to Customization is enough. Don’t re-list global tokens per component.

Before opening a PR

Run npm run build once to confirm both the bundles and the static site render clean. Open the demo page for your new component in dev and check it at 320, 768, 1024, and 1440. Walk every state under light and dark.

One component per PR keeps reviews scoped. A new customization knob can ride along with the component that introduces it.