sveltelean
Versionv0.2.0 GitHub

Setup

Tailwind v4 puts its CSS in the cascade layers theme, base (preflight), components and utilities. Every file of this package declares its rules in sublayers of one layer, slean (slean.reset, slean.tokens, slean.base, slean.components, slean.compositions, slean.utilities). A layer statement at the top of the stylesheet places that layer between preflight and Tailwind's components:

src/app.css
/* src/app.css. The layer statement comes first: Tailwind's theme and preflight (base) sit
 * below Svelte Lean, Tailwind's components and utilities above it. The package files declare
 * their rules in slean.* sublayers, so they are imported without layer(). */
@layer theme, base, slean, components, utilities;

@import 'tailwindcss';

@import '@svelte-lean/styles/tokens.css';
@import '@svelte-lean/styles/base.css';
@import '@svelte-lean/styles/button.css';
@import '@svelte-lean/styles/input.css';
@import '@svelte-lean/styles/menu.css';

/* Tailwind's dark: variant follows the same class the tokens read. */
@custom-variant dark (&:where(.dark, .dark *));
  • The layer statement must come before @import 'tailwindcss': the first statement that names a layer fixes its position. Without it, slean is created after utilities and the package rules win over every utility.
  • Import the package files without layer(). They already declare slean.* sublayers; wrapping them in another layer would nest them one level deeper, under a layer the statement does not order.
  • Import only the files the page uses. A file that builds on another imports it itself (menu.css brings popover.css); Tailwind's import inliner includes a file once.

Preflight

Preflight lives in base, below slean. Its resets (no border, no padding, transparent button backgrounds, display: block on svg) apply first and the package rules replace them on every element that carries data-slean or data-slean-part: a button keeps its accent fill and 36px height, an input its border, a checkbox its drawn box. Content inside a primitive that is not a part (the body of a dialog, a tab panel) keeps preflight, like the rest of the page.

Tokens as Tailwind colors

@theme inline turns the --slean-* tokens into Tailwind colors and radii, so markup next to the primitives can use bg-primary, border-input or text-muted-foreground. inline matters: the utilities keep the var(), so they follow the dark palette and every theme scope instead of the value at build time.

src/app.css
/* Tailwind colors and radii that read the Svelte Lean tokens. inline keeps the var() in the
 * generated utilities, so a theme scope or the dark palette changes them at run time. */
@theme inline {
	--color-background: var(--slean-bg);
	--color-foreground: var(--slean-fg);
	--color-surface: var(--slean-surface);
	--color-muted: var(--slean-muted);
	--color-muted-foreground: var(--slean-fg-muted);
	--color-border: var(--slean-border);
	--color-input: var(--slean-control-border);
	--color-ring: var(--slean-focus-ring-color);
	--color-primary: var(--slean-accent);
	--color-primary-foreground: var(--slean-accent-fg);
	--color-destructive: var(--slean-danger);
	--radius-sm: var(--slean-radius-sm);
	--radius-md: var(--slean-radius-md);
	--radius-lg: var(--slean-radius-lg);
}

The names follow the shadcn convention; any other naming works the same way. The reverse direction is an ordinary token override that points the package at Tailwind's theme variables:

src/app.css
/* The other direction: point the Svelte Lean tokens at Tailwind's theme variables, so the
 * primitives take the palette of an existing Tailwind design. Unlayered, like any override. */
:root {
	--slean-accent: var(--color-indigo-600);
	--slean-accent-hover: var(--color-indigo-700);
	--slean-accent-active: var(--color-indigo-800);
	--slean-accent-soft: var(--color-indigo-50);
	--slean-accent-soft-fg: var(--color-indigo-700);
	--slean-font-sans: var(--font-sans);
}

Dark mode

The tokens switch to the dark palette under prefers-color-scheme: dark, on data-theme="dark" and on the dark class, on the root or on any subtree (Themes). The @custom-variant line in the setup gives Tailwind's dark: variant the same class, so a class written by a toggle or by mode-watcher switches both.

Overriding a primitive with utilities

The utilities layer comes after slean, so a utility wins over the package declaration it meets, whatever the package selector is. The package styles its states (:hover, :focus-visible, :disabled) with rules of their own; a utility without a variant replaces the property in every state, so pair it with the variant, as hover:bg-emerald-700 does here.

Page.svelte
<!-- The utilities layer comes after slean, so a utility wins over the package rule it
     meets, whatever the selector. A state the package styles needs its own variant: a plain
     bg-* also replaces the hover background. -->
<button
	type="button"
	data-slean="button"
	class="rounded-full bg-emerald-600 px-8 shadow-lg hover:bg-emerald-700"
>
	Publish
</button>

<input type="text" data-slean="input" class="w-64 rounded-none border-2 border-primary" />

<!-- Tailwind's own markup can use the token colors from @theme inline. -->
<p class="rounded-md border border-border bg-muted px-2 py-1 text-muted-foreground">…</p>

Note A utility changes one element. To change every button, override a token (--slean-radius-md, --slean-accent) or write an unlayered rule on the protocol attribute; both reach the primitives that are not in your markup, such as the rows of a select's list.