Skip to content

Component styles

Each CSS module, and each <style lang="scss"> block in a Vue, Svelte or Astro component, is a separate Sass compile. When that file loads the main entry, @wovenui/core, every component repeats the tokens, the reset and the base styles. Worse, a component that loads core without your configuration emits the default tokens again, after the configured ones in the global stylesheet: a card compiled that way carries --wu-brand-500: #007bff while the app is configured with #4361ee. The @wovenui/core/mixins entry fixes both. It has the same API as @wovenui/core and outputs no CSS.

Three files: one partial with the configuration, one global stylesheet loaded once by the app, and any number of components.

src/styles/_woven.scss
@forward "@wovenui/core/mixins" with ($brand: #4361ee);
// src/styles/global.scss, loaded once by the app
@use "woven";
@use "@wovenui/core";
src/components/Card.module.scss
@use "../styles/woven" as *;
.card {
@include apply(p(4), bg(surface), rounded(md), shadow(md) hover, p(6) md);
}
  • The partial holds the configuration. It forwards the mixins entry with your with() settings. Components and the global stylesheet load the partial, so both see the same configured modules.
  • The global stylesheet emits the CSS. Loading @wovenui/core there writes the tokens, the reset, the base styles and, when enabled, the utility classes, with the configured brand: the compiled file has --wu-brand-500: #4361ee.
  • Components write their own rules only. apply(), every mixin, token() and the configuration variables come through the partial.
Card.module.scss rendered; for the live preview the partial is replaced by the package entry it forwards, @wovenui/core/mixins (hover it, pick a width past md)
@use "@wovenui/core/mixins" as *;
.card {
@include apply(p(4), bg(surface), rounded(md), shadow(md) hover, p(6) md);
}

@wovenui/core forwards the same modules as @wovenui/core/mixins. Sass loads a module once per compile and accepts its configuration only on the first load. With @use "woven" first, the partial configures the modules and @wovenui/core reuses them. With the lines swapped, core loads with the defaults and the partial’s with() fails:

// src/styles/global.scss: wrong order
@use "@wovenui/core";
@use "woven";
Error: This module was already loaded, so it can't be configured using "with".
1 │ @forward "@wovenui/core/mixins" with ($brand: #4361ee);
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ new load
2 │ @forward "mixins";
│ ━━━━━━━━━━━━━━━━━ original load

The first frame is _woven.scss, the second is core’s index.scss. Configuring core directly after loading it fails with the same message. See Configuration for the with() rules.

Card.module.scss above compiles to its own rules and nothing else:

.card {
padding: var(--wu-space-4);
background-color: var(--wu-bg-surface);
border-radius: var(--wu-radius-md);
}
.card:hover {
box-shadow: var(--wu-shadow-md);
}
@media (min-width: 768px) {
.card {
padding: var(--wu-space-6);
}
}

The same card compiled through the main entry (@use "@wovenui/core" as *;) carries the whole foundation layer with it. Measured with Dart Sass 1.105.1 and wc -c:

Card compiled through Expanded Compressed
@wovenui/core/mixins (the partial) 247 bytes 206 bytes
@wovenui/core 48,953 bytes 43,193 bytes

Through the main entry, every component repeats those 43 KB. Through the partial, it adds only its own rules.

The global stylesheet is the only file that emits the foundation layer, so load it on every page:

  • Tokens. Every --wu-* token: the fixed :root block, the light and dark themes, and the high-contrast blocks when set. Component rules read them through var().
  • Reset and base layer. @layer reset, base, components, the base element styles and .sr-only.
  • @property registrations. 13 registrations: the transform variables, --wu-gap and the transition group variables.

Component mixins that read the registered variables need the global stylesheet on the page:

Mixins Variables they read
Transforms: translate-x(), scale(), rotate(), skew-x() and the rest --wu-translate-x, --wu-scale-x, --wu-rotate, --wu-skew-x …
gap(), gap-x() and fill() --wu-gap
transition-colors, transition-opacity, transition-shadow, transition-transform --wu-transition-colors …

For example, @include apply(translate-x(4) hover); in a component writes transform: translateX(var(--wu-translate-x)) translateY(var(--wu-translate-y)) .... The unset variables take their registered initial values from the @property rules in the global stylesheet.

Keyframes are the exception: an animation mixin adds its keyframes in whichever compile uses it. A component with @include animate-spin; emits:

.spinner {
animation: anim-spin-normal 1s linear infinite;
}
@keyframes anim-spin-normal {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
  • $generate-utility-classes affects the global stylesheet only. With $generate-utility-classes: true in the partial, the global stylesheet grows from 48,706 to 1,180,439 bytes (expanded) and the card stays 247 bytes, byte for byte the same. Components never emit classes.
  • Feature flags apply everywhere. The partial configures the mixins too. With $feature-flags: ("theme": false), apply(p(4), rounded(md)) in a component gives padding: 1rem; border-radius: 0.5rem;, and bg(surface) raises an error because no tokens are emitted.

Load the partial with a namespace to keep component files free of global names:

@use "../styles/woven" as woven;
.card {
@include woven.apply(p(4), bg(surface), shadow(md) hover);
border-color: woven.token(border-regular);
}
.card {
padding: var(--wu-space-4);
background-color: var(--wu-bg-surface);
}
.card:hover {
box-shadow: var(--wu-shadow-md);
}
.card {
border-color: var(--wu-border-regular);
}
The namespaced card rendered, with the package entry loaded as woven in place of the partial
Namespaced card

Hover it for the shadow; the border color comes from woven.token().

@use "@wovenui/core/mixins" as woven;
.card {
@include woven.apply(p(4), bg(surface), shadow(md) hover);
border-color: woven.token(border-regular);
}

In a namespaced load the items reach apply() as text, which has three limits. Each is set by Sass, and each has a working form.

Limit Fails Works
A map cannot be written into a text item woven.apply(animate-spin((duration: 2s))): (duration: 2s) isn't a valid CSS value. woven.apply(woven.animate-spin((duration: 2s)))
opacity() with anything but a number is the Sass color function woven.apply(opacity(inherit)): $color: inherit is not a color. woven.apply(woven.opacity(inherit)), or opacity(50) for a key
A color function other than rgb() or rgba() takes no shade woven.apply(bg(oklch(60% 0.15 250), 600)): error, the color takes no shade in namespaced apply() apply(bg(oklch(60% 0.15 250), 600)) after @use ... as *, which gives #1272c1

Everything else matches apply() after @use ... as *. See apply() for the full syntax.

token() works in any declaration of a component:

@use "../styles/woven" as *;
.panel {
padding: token(space-4);
color: token(text-muted);
}
.panel {
padding: var(--wu-space-4);
color: var(--wu-text-muted);
}
token() in plain declarations, rendered: padding from space-4, muted text, with the package entry in place of the partial
Plain CSS declarations that read WovenUI tokens.
@use "@wovenui/core/mixins" as *;
.panel {
padding: token(space-4);
color: token(text-muted);
background: token(bg-alternate);
border-radius: token(radius-md);
}

Unlike a hand-written var(--wu-...), token() is checked at compile time. A typo stops the build:

Error: "token(text-mutd): unknown token. Close matches: text-muted."
╷
4 │ color: token(text-mutd);
│ ^^^^^^^^^^^^^^^^
╵

The tokens page lists every name.

Every framework uses the same three files. Only the place the component styles live and the file that loads global.scss change.

Framework Component styles Global stylesheet loaded in Guide
React CSS module, src/components/Card.module.scss src/main.tsx React
Vue <style scoped lang="scss"> in src/components/Card.vue src/main.js Vue
Svelte <style lang="scss"> in src/lib/Card.svelte, compiled through vitePreprocess in svelte.config.js src/main.js Svelte
Astro <style lang="scss"> in src/components/Card.astro, scoped to the component src/layouts/Layout.astro Astro

Svelte compiles <style lang="scss"> only through a preprocessor. Without vitePreprocess, apply() reaches the Svelte compiler as plain CSS and no rule is written. For installing the package and the Sass version, see Installation.