vitus·labs
Styler

Styler

CSS-in-JS engine for Vitus Labs UI System — 4.82 KB gzipped, React 19 SSR via <style precedence>, regression-gated competitive bench.

@vitus-labs/styler is a CSS-in-JS engine with a styled-components-compatible API: tagged template literals, styled.tag shorthand, as polymorphism, $-prefixed transient props, refs, theming, keyframes, createGlobalStyle, and SSR. The engine has a static/dynamic split with multi-tier caching and uses React 19's <style precedence> for SSR streaming.

Installation

npm install @vitus-labs/styler

Peer dependencies: react >= 19, react-dom >= 19

Key Features

  • 4.82 KB gzipped (12.21 KB minified, fresh build) — full SSR engine with @layer, at-rule splitting, and concurrent-mode-safe injection.
  • Static/dynamic split — templates with no function interpolations compute their class name once at module load; subsequent renders return a pre-built ReactElement with no resolve / hash / createElement work.
  • FNV-1a hashing — deterministic class names (vl-<base36>), automatic dedup. Hash loop is 4-char-unrolled for ~+15-46% throughput vs the per-char loop.
  • CSS-in-CSS composition — nest css\…`results insidestyled`…`or othercss`…``. Static nested results are memoized per-instance (one resolve, reused across every consumer's render).
  • React 19 SSR<style precedence="medium"> on server, useInsertionEffect into a single shared <style data-vl> element on client. No manual collectStyles / ServerStyleSheet boilerplate.
  • Transient props$-prefixed props consumed by styles but never forwarded to DOM.
  • Polymorphic as prop — render as any element or component without breaking ref forwarding.
  • Specificity boostboost: true doubles the selector to (0,2,0) for library-component overrides.
  • @layer support — opt in via createSheet({ layer: 'components' }).
  • Bounded cachemaxCacheSize (default 10,000) with 10% oldest-first eviction.
  • Every tag worksstyled.div, styled.p, styled.section, styled('my-element') all hit the same Proxy handler. Zero bytes per tag added to the bundle.
  • CI-gated perf — every PR runs the bench workflow which fails on >10% regression vs main on the same runner.

Try It Live

function StyledDemo() {
const [color, setColor] = React.useState('#0d6efd')
const [radius, setRadius] = React.useState(8)

return (
  <div style={{ fontFamily: 'system-ui' }}>
    <div style={{ display: 'flex', gap: 12, marginBottom: 12 }}>
      <label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 14 }}>
        Color:
        <input type="color" value={color} onChange={(e) => setColor(e.target.value)} />
      </label>
      <label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 14 }}>
        Radius: {radius}px
        <input type="range" min={0} max={24} value={radius} onChange={(e) => setRadius(Number(e.target.value))} />
      </label>
    </div>
    <button style={{
      background: color,
      color: 'white',
      border: 'none',
      borderRadius: radius,
      padding: '10px 20px',
      fontSize: 14,
      cursor: 'pointer',
      fontWeight: 500,
    }}>
      Styled Button
    </button>
  </div>
)
}

render(<StyledDemo />)

css()

Tagged template for composable CSS fragments. Returns a lazy CSSResult object — no CSS resolution happens at creation time.

import { css } from '@vitus-labs/styler'

// Static — stored as-is, resolved when consumed
const highlight = css`
  color: red;
  font-weight: bold;
`

// Dynamic — function interpolations resolved at render time with props + theme
const dynamic = css`
  color: ${(props) => props.$color || 'blue'};
  padding: ${(props) => props.$size}px;
`

// Composition — nest css results inside other css
const combined = css`
  ${highlight}
  border: 1px solid gray;
`

// Array interpolation — flattened automatically
const responsive = css`
  ${[
    css`color: red;`,
    css`margin: 0;`,
  ]}
`
// Resolves to: "color: red;margin: 0;"

CSSResult

css() returns a CSSResult instance that stores the raw template strings and interpolation values:

class CSSResult {
  readonly strings: TemplateStringsArray
  readonly values: Interpolation[]

  // Resolve with empty props (useful for testing/debugging)
  toString(): string
}
  • No computation at creation time — only stores references
  • Resolved when consumed by styled, useCSS, or toString()
  • Instance check: value instanceof CSSResult detects nested CSS for composition
  • Thunk pattern: When used as interpolation before engine init, returns a thunk resolved at render

Interpolation Types

TypeBehavior
string / numberInserted directly into CSS
CSSResult (from another css call)Recursively resolved and flattened
Interpolation[] (array)Each element resolved and concatenated
(props) => InterpolationCalled at render time with { ...componentProps, theme }
true / falseConverted to empty string (enables ${condition && css\...`}`)
null / undefinedConverted to empty string
type Interpolation =
  | string
  | number
  | boolean
  | null
  | undefined
  | CSSResult
  | Interpolation[]   // recursive
  | ((props: { theme?: DefaultTheme; [key: string]: any }) => Interpolation)

CSS Normalization

Resolved CSS goes through a single-pass normalization that:

  • Strips /* block comments */ and // line comments (preserves :// in URLs)
  • Collapses whitespace to single spaces
  • Removes redundant semicolons (after {, }, other ;)

styled()

Component factory that creates styled React components with automatic class name management, ref forwarding, and prop filtering.

import { styled } from '@vitus-labs/styler'

// Tag shorthand via Proxy
const Box = styled.div`
  padding: 16px;
  border-radius: 8px;
`

// Direct call syntax
const Card = styled('section')`
  background: white;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
`

// Wrap existing component
const StyledButton = styled(MyButton)`
  cursor: pointer;
`

// With options
const Custom = styled('div', {
  shouldForwardProp: (prop) => !prop.startsWith('custom'),
  boost: true,
})`
  padding: 16px;
`

Options

styled(tag: string | ComponentType, options?: StyledOptions)
OptionTypeDefaultDescription
shouldForwardProp(prop: string) => booleanControl which props reach the DOM element
boostbooleanfalseDouble the selector (.vl-abc.vl-abc) for specificity (0,2,0)

Dynamic Props & Transient Props

Props prefixed with $ are automatically filtered from DOM output:

const Button = styled.button`
  background: ${(props) => props.$variant === 'primary' ? '#0d6efd' : '#6c757d'};
  color: white;
  padding: ${(props) => props.$size === 'large' ? '12px 24px' : '8px 16px'};
`

<Button $variant="primary" $size="large">Click me</Button>
// Renders: <button class="vl-abc123">Click me</button>
// $variant and $size consumed by styles, NOT forwarded to <button>

Polymorphic as Prop

Override the rendered element type at the call site:

const Box = styled.div`
  padding: 16px;
`

<Box as="section" />      // renders <section>
<Box as="article" />      // renders <article>
<Box as={MyComponent} />  // renders <MyComponent>

The as prop works in both static and dynamic paths and is never forwarded to the underlying element.

Ref Forwarding

All styled components forward refs via React.forwardRef:

const Input = styled.input`
  border: 1px solid gray;
`

const ref = useRef<HTMLInputElement>(null)
<Input ref={ref} />  // ref points to the <input> DOM element

shouldForwardProp

For custom prop filtering logic:

const Input = styled('input', {
  shouldForwardProp: (prop) => prop !== 'hasError',
})`
  border-color: ${(props) => props.hasError ? 'red' : 'gray'};
`

<Input hasError />
// hasError used by styles but NOT forwarded to <input>

shouldForwardProp only applies to DOM elements (string tags). When wrapping React components, all props are forwarded.

Default Prop Filtering

For DOM elements without custom shouldForwardProp:

Prop PatternForwarded?
$-prefixed (e.g., $color)No — transient props
asNo — consumed for polymorphism
data-*Yes
aria-*Yes
Known HTML attributesYes
Unknown propsNo — prevents React warnings

Class Name Merging

User-provided className is merged with the generated class:

<Box className="extra">Hello</Box>
// Renders: <div class="vl-abc123 extra">Hello</div>

Specificity Boost

Double the selector to raise specificity from (0,1,0) to (0,2,0):

const Override = styled('div', { boost: true })`
  color: red;
`
// Generates: .vl-abc.vl-abc { color: red; }
// Overrides inner library components regardless of CSS source order

Static vs Dynamic Paths

The engine automatically detects whether a template has dynamic interpolations and uses the optimal path:

Static Path

When no interpolation values are functions (or nested CSSResults containing functions):

  1. CSS resolved once at component creation time (module evaluation)
  2. Class name computed and CSS injected immediately
  3. Zero per-render overhead — no theme context access, no resolution
  4. Component simply applies the pre-computed class name
// Static — class computed once, shared across all instances
const Box = styled.div`
  padding: 16px;
  margin: 8px;
  background: white;
`

Dynamic Path

When any interpolation is a function:

  1. Theme accessed via useTheme() on every render
  2. CSS resolved with { ...componentProps, theme }
  3. useRef cache — CSS string comparison avoids rehashing when content unchanged
  4. useInsertionEffect injects CSS synchronously before paint
  5. Dedup cache prevents duplicate style injection
// Dynamic — resolved per render, cached by CSS string comparison
const Box = styled.div`
  padding: ${(props) => props.$p}px;
  color: ${(props) => props.theme.colors.primary};
`

keyframes()

Create CSS @keyframes animations. Injection is synchronous and immediate.

import { keyframes, styled } from '@vitus-labs/styler'

const fadeIn = keyframes`
  from { opacity: 0; transform: translateY(-10px); }
  to { opacity: 1; transform: translateY(0); }
`

const FadeInBox = styled.div`
  animation: ${fadeIn} 0.3s ease-in-out;
`

Behavior

  • Returns a KeyframesResult with a .name property (vl-kf-<hash>)
  • String coercion returns the animation name for use in CSS interpolations
  • Deterministic naming via FNV-1a hash of the CSS body
  • No dynamic interpolations — keyframes are always static
  • Injected as @keyframes vl-kf-<hash> { ... } into the global sheet
  • Deduplicated by animation name

createGlobalStyle()

Inject unscoped global CSS. Returns a React component.

import { createGlobalStyle } from '@vitus-labs/styler'

const GlobalStyles = createGlobalStyle`
  * {
    box-sizing: border-box;
    margin: 0;
  }

  body {
    font-family: system-ui, sans-serif;
    background: ${(props) => props.theme?.background || '#fff'};
  }
`

function App() {
  return (
    <>
      <GlobalStyles />
      <Main />
    </>
  )
}

Behavior

  • Returns a React component (not a CSSResult)
  • Component props are merged with theme for interpolation resolution
  • Unscoped — CSS injected without any .vl-* class wrapper
  • Static path: CSS resolved once at creation, component renders nothing on client
  • Dynamic path: CSS resolved per render, injected via useInsertionEffect
  • SSR: Renders <style precedence="low"> — lower precedence than component styles (medium)
  • Deduplicated by hash of resolved CSS

useCSS()

Hook that resolves a CSSResult to a class name string. Use when you need styling without creating a styled component wrapper.

import { css, useCSS } from '@vitus-labs/styler'

const highlight = css`
  color: red;
  font-weight: bold;
`

function Label({ children }) {
  const className = useCSS(highlight)
  return <span className={className}>{children}</span>
}

Signature

useCSS(
  template: CSSResult,
  props?: Record<string, any>,
  boost?: boolean
): string  // class name

Dynamic Styles

const badge = css`
  background: ${(props) => props.variant === 'success' ? 'green' : 'gray'};
  padding: 4px 8px;
  border-radius: 4px;
`

function Badge({ variant, children }) {
  const className = useCSS(badge, { variant })
  return <span className={className}>{children}</span>
}

Theme is automatically merged from context. Same caching and injection mechanism as styled().

CSS Nesting

Native CSS nesting with & selectors is fully supported:

const Card = styled.div`
  background: white;
  padding: 16px;

  & h2 {
    margin-bottom: 8px;
    font-size: 1.5rem;
  }

  & p {
    color: #666;
  }

  &:hover {
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  }

  &.active {
    border-color: blue;
  }
`

At-Rule Splitting

@media, @supports, and @container at-rules are automatically extracted from the CSS body and emitted as separate top-level rules with the selector wrapped inside. This works around CSSOM spec limitations:

const Responsive = styled.div`
  font-size: 14px;
  @media (min-width: 600px) {
    font-size: 18px;
  }
`
// Emits two rules:
// .vl-abc { font-size: 14px; }
// @media (min-width: 600px) { .vl-abc { font-size: 18px; } }

Non-splittable at-rules (@keyframes, @font-face) are left in place.

Deduplication & Caching

  • Same CSS always produces the same FNV-1a hash → same class name
  • If two components generate identical CSS, the rule is injected once and both share the class name
  • Dynamic path uses a useRef cache per component instance — string comparison skips rehashing when CSS unchanged between renders
  • Global StyleSheet cache bounded by maxCacheSize (default 10,000) with 10% eviction on overflow

HMR Support

For development with hot module replacement:

import { sheet } from '@vitus-labs/styler'

if (import.meta.hot) {
  import.meta.hot.accept(() => {
    sheet.clearAll()  // Purge all cached + injected styles
  })
}
  • sheet.clearCache() — Clears the dedup cache plus the resolve-side caches (insertCache, prepareCache, normCache). Keeps the already-injected CSS in the DOM. Useful for HMR scenarios where you want to recompute classes but not flush rules.
  • sheet.clearAll() — Clears everything clearCache() does, plus the SSR buffer, plus removes all rules from the DOM. Also fires registered clearCallbacks so consumers like styled.ts reset their staticComponentCache and hotCache. Use for full HMR resets.

Benchmarks

Bundle size

Fresh build of @vitus-labs/styler (rolldown, ESM, react externalized):

BuildSize
Bundle (unminified)33.97 KB
Minified12.21 KB
Gzipped4.82 KB

Competitor numbers below are from each library's published artifact (read off bundlephobia / their dist files); they're a snapshot, not a regression-gated measurement:

LibraryGzipped
goober~1.3 KB
@vitus-labs/styler4.82 KB
@emotion/react + styled~16.6 KB
styled-components 6~17.9 KB

If you need the smallest possible bundle and don't need SSR or full HTML-prop filtering, goober is the minimal option. styler's positioning is "full SSR + composition + dedup + caching in under 5 KB gzipped."

What drives the size

ModuleWhat it ships
sheet.tsStyleSheet class, SSR hydration, @media/@supports/@container rule splitting, @layer wrapping, bounded cache + eviction, prepare() cache for <style precedence>, onClear broadcast hooks
styled.tsstatic/dynamic split, hot + WeakMap component cache, LRU-2 cssText cache per dynamic render, polymorphic as prop, SSR vs client branches
forward.tsHTML attribute allowlist, transient ($-prefix) prop filter, shouldForwardProp option
resolve.tstagged-template resolver, normalizeCSS single-pass scanner, _isDynamic / _staticResolved per-instance caches
globalStyle.tscreateGlobalStyle (static + dynamic paths, SSR precedence)
Otherhash (4-char-unrolled FNV-1a), useCSS, keyframes, ThemeProvider, evict, css, shared, index

Competitive bench (render-to-string + CSR)

The canonical regression-gated suite is in-repo at packages/styler/benchmarks/css-perf.bench.tsx. Run with bun run bench from the styler package. Same-process tinybench, equivalent work (styled-components is wrapped in ServerStyleSheet so all three libraries serialize CSS on the SSR rows). Numbers below are 3-run medians on bun 1.3.13 / react 19.2.6.

Scenariostyler ops/semotion ops/ssc 6 ops/sstyler vs best competitor
SSR static (500 renders/tick)7193352102.14× vs emotion
SSR dynamic (500 renders/tick)4343072021.41× vs emotion
SSR themed (500 renders/tick)3632861841.27× vs emotion
CSR mount (100 mounts/tick)139121381713702+1% vs emotion (tie)
CSR update (100 updates/tick)131081303213239−1% vs sc (tie)
CSR many (50 distinct comps/tick)189331830257053.32× vs sc

The SSR gap is where styler's React 19 <style precedence> + static-element cache pay off — both emotion and styled-components have to serialize via heavier paths (sc explicitly via ServerStyleSheet, emotion via its own SSR plumbing). The CSR mount/update numbers are within ~1% across all three libraries because React's reconciler dominates the per-render cost.

A >10% regression on any styler row vs main on the same runner fails the bench workflow. This caught a real regression during the 2.6.x perf round and forced a revert before merge.

Helper-level microbench (audit history)

A second bench at packages/styler/benchmarks/perf-audit-bench.tsx compares individual helper old-vs-new in the same process. This is the bench used to verify each perf-PR's claimed delta during audit; representative recent results (median of 3 runs):

HelperΔ vs main-before-PR
CSSResult._staticResolved cache (nested-static, 8 repeats)+149%
hashUpdate 4-char unroll (337-char CSS)+46%
hashUpdate 4-char unroll (25-char CSS)+15%
HTML_PROPS Set → null-proto key in obj (5-lookup mix)+19%

These translate into ~0% headline bench movement (the helpers target paths the headline bench doesn't exercise — nested static composition, very long CSS strings, deep buildProps calls). They land for real-app workloads with shared css\…`` snippets, long stylesheets, and prop-heavy components.

On this page