@davaux/scoped-css

Location-based CSS scoping for Davaux. CSS files imported from src/islands/ are automatically scoped — every class name gets a short deterministic hash suffix so island styles never leak into the rest of the page. No .module.css convention needed.

Installation

npm install @davaux/scoped-css

Setup

Register the plugin in davaux.config.ts:

// davaux.config.ts
import { defineConfig } from 'davaux/config'
import { scopedCss } from '@davaux/scoped-css'

export default defineConfig({
  plugins: [scopedCss()],
})

Add the ambient type declaration so TypeScript accepts CSS default imports:

// src/env.d.ts
/// <reference types="@davaux/scoped-css/env" />

How scoping works

CSS imported from inside src/islands/ (or CSS files that live there) are intercepted at build time. Each class name is rewritten with a stable 5-character hash derived from the file path:

/* src/islands/Button.css — authored */
.button { background: blue; }
.active { background: darkblue; }

/* emitted to the bundle */
.button-a1b2c { background: blue; }
.active-a1b2c { background: darkblue; }

The import returns a Record<string, string> mapping original names to their scoped counterparts:

import styles from './Button.css'
// styles.button === 'button-a1b2c'
// styles.active === 'active-a1b2c'

CSS outside src/islands/ is never scoped — global styles and layout CSS work exactly as before.

Basic usage

// src/islands/Button.tsx
import styles from './Button.css'
import { island } from 'davaux'

export const Button = island('Button', () => {
  return <button class={styles.button}>Click me</button>
})

Opting out with ?global

To import a CSS file without scoping — a reset sheet, a third-party stylesheet — append ?global:

import './normalize.css?global'

The file is passed through the normal CSS pipeline without any class name rewriting.

cx() — class name composition

The cx helper from @davaux/scoped-css/client composes class names from strings and condition maps:

import { cx } from '@davaux/scoped-css/client'

cx(styles.button, styles.large)
// → 'button-a1b2c large-a1b2c'

cx(styles.button, { [styles.active]: isActive })
// → 'button-a1b2c active-a1b2c'  (when isActive is true)
// → 'button-a1b2c'               (when isActive is false)

Falsy arguments (false, null, undefined) are skipped:

cx(styles.button, isLoading && styles.loading)
// → 'button-a1b2c loading-a1b2c'  or  'button-a1b2c'

Reactive cx() with signals

When any condition in a map is a signal getter, cx returns a reactive getter () => string instead of a plain string. The JSX runtime wraps it in a createEffect so only the class attribute updates when the signal changes — the rest of the DOM is untouched.

import { createSignal } from 'davaux'
import { cx } from '@davaux/scoped-css/client'
import styles from './Button.css'

const [active, setActive] = createSignal(false)

// cx detects that `active` is a function → returns () => string
const className = cx(styles.button, { [styles.active]: active })

<button class={className} onClick={() => setActive((v) => !v)}>
  Toggle
</button>

Or inline directly in JSX — the JSX runtime handles it automatically:

<button class={cx(styles.button, { [styles.active]: active })}>
  Toggle
</button>

cx argument types

ArgumentEffect
stringIncluded as-is
false | null | undefinedSkipped
{ [cls]: boolean }Includes cls when value is true
{ [cls]: () => boolean }Reactive — cx returns () => string

TypeScript

The @davaux/scoped-css/env reference adds a declare module '*.css' ambient declaration so TypeScript treats CSS default imports as Record<string, string>. Add it to src/env.d.ts once per project:

/// <reference types="@davaux/scoped-css/env" />

If you need the cx argument type in your own helpers:

import type { CxArg } from '@davaux/scoped-css/client'

function buildClass(...args: CxArg[]): string | (() => string) {
  // ...
}

SCSS and SASS

Because scoping happens on the output CSS (after esbuild processes the file), SCSS/SASS works without any extra configuration — just run the SCSS through a pre-processor first and import the resulting .css file, or use an esbuild SCSS plugin alongside @davaux/scoped-css. The hash is derived from the source file path so scoped names stay stable regardless of the CSS toolchain upstream.

Notes

  • Class names are hashed from the absolute file path — the same source file always produces the same scoped names across builds.
  • Only standalone class selectors are rewritten. Attribute selectors ([class~="btn"]), string values, and CSS custom properties are left untouched.
  • The scoped CSS and JS shim are both emitted to the existing bundle — no separate CSS file per component.