Skip to content

Generate scoped class names from JavaScript objects. Auto-injects a <style> tag.

function sheet<T extends SheetDef>(definitions: T): SheetResult<T>
ParamTypeDescription
definitionsTObject mapping class names to CSS rules

Object where each key is a scoped class name string. Also includes a .cls() helper.

import { sheet, div, h2, p } from "@whisq/core";
const s = sheet({
card: {
padding: "1.5rem",
borderRadius: "12px",
background: "#fff",
"&:hover": { background: "#f5f5f5" },
},
title: { fontSize: "1.25rem", fontWeight: 600 },
});
div({ class: s.card }, h2({ class: s.title }, "Hello"), p("World"))

Two buckets, resolved by the leading character of the key:

  • &-prefixed keys — the leading & is dropped and the rest of the key is appended verbatim to the scoped class name. So "&:hover" becomes .wq0_card:hover, "& > span" becomes .wq0_card > span, and so on. Anything valid as a CSS suffix works — pseudo-classes, pseudo-elements, combinators, attribute selectors, :not(), :has(), etc.
  • @-prefixed keys — wrapped around the class rule. "@media (min-width: 768px)" becomes @media (min-width: 768px){.wq0_card{...}}. @supports, @container, and any other conditional at-rule work the same way.
sheet({
btn: {
background: "#333",
color: "#fff",
"&:hover": { background: "#555" },
"&:focus-visible": { outline: "2px solid var(--color-accent)" },
"&:disabled": { opacity: 0.5, cursor: "not-allowed" },
"&:active": { background: "#222" },
},
})
sheet({
card: {
position: "relative",
"&::before": { content: '""', position: "absolute", inset: 0 },
"&::after": { content: '"★"', position: "absolute", top: "0.5rem", right: "0.5rem" },
},
field: {
padding: "0.5rem",
"&::placeholder": { color: "var(--color-text-muted)" },
},
})

Stack pseudo-classes and pseudo-elements directly — the whole suffix is concatenated.

sheet({
toggle: {
"&:checked::after": { content: '"✓"', color: "var(--color-success)" },
"&:hover:not(:disabled)": { background: "#555" },
"&:has(:focus-visible)": { outline: "2px solid var(--color-accent)" },
},
})

Combinators (descendants, children, siblings)

Section titled “Combinators (descendants, children, siblings)”
sheet({
list: {
listStyle: "none",
padding: 0,
"& > li": { padding: "0.5rem", borderBottom: "1px solid #eee" },
"& a": { color: "var(--color-accent)", textDecoration: "none" },
"& a:hover": { textDecoration: "underline" },
"& > li + li": { marginTop: "0.25rem" },
},
})
sheet({
dialog: {
display: "none",
"&[open]": { display: "block" },
'&[data-state="entering"]': { animation: "fade-in 150ms" },
},
btn: {
background: "#333",
"&.primary": { background: "var(--color-primary)" },
"&.danger": { background: "var(--color-danger)" },
"&.small": { padding: "0.25rem 0.5rem" },
},
})
sheet({
card: {
padding: "1rem",
display: "block",
"@media (min-width: 768px)": { padding: "2rem" },
"@supports (display: grid)": { display: "grid", gap: "1rem" },
"@container (max-width: 400px)": { fontSize: "0.875rem" },
},
})

On the server (typeof document === "undefined"), sheet() returns the in-memory classMap as usual — server-rendered HTML can reference the correct class names for the client to hydrate against — but skips the DOM injection step. The styles aren’t written to a <style> tag on the server; client-side hydration injects them on mount.

Previously threw ReferenceError: document is not defined at the injection line. The fix in alpha.8 is the same internal injectCSS() short-circuit that theme() and persistedSignal() already use.

If your SSR pipeline needs the styles inlined into server HTML (e.g. for first-paint correctness without a hydration flash), emit them yourself by walking the returned classMap and serializing the rules into a <style> block in your SSR template.

  • Single level of nesting. Nested rules are flat { property: value } objects — you cannot nest a &- or @- key inside another nested block. To express “the hover state of a descendant,” combine the selectors yourself: "&:hover > span" rather than "&:hover": { "& > span": { ... } }.
  • No global / top-level selectors. sheet() only emits scoped class rules. For global resets or tag-level styling, inject a <style> tag manually or use a separate CSS file.
  • & must be the first character of the key. The engine only processes keys that start with & or @. A key like ".parent", "> span", or "span &" is silently mishandled — it goes into the base rule and is serialized as a CSS property, producing garbage output without any error. This is the footgun to watch for: always start your nested keys with & or @.
  • No SCSS-style & substitution mid-selector. The engine does not scan for & beyond the leading position. Writing "&:hover & > span" emits .cls:hover & > span — the second & stays literal and is invalid CSS.

Docs current to v0.1.0-alpha.9 . All releases →