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/stylerPeer 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 /
createElementwork. - 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,useInsertionEffectinto a single shared<style data-vl>element on client. No manualcollectStyles/ServerStyleSheetboilerplate. - Transient props —
$-prefixed props consumed by styles but never forwarded to DOM. - Polymorphic
asprop — render as any element or component without breaking ref forwarding. - Specificity boost —
boost: truedoubles the selector to (0,2,0) for library-component overrides. - @layer support — opt in via
createSheet({ layer: 'components' }). - Bounded cache —
maxCacheSize(default 10,000) with 10% oldest-first eviction. - Every tag works —
styled.div,styled.p,styled.section,styled('my-element')all hit the sameProxyhandler. 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, ortoString() - Instance check:
value instanceof CSSResultdetects nested CSS for composition - Thunk pattern: When used as interpolation before engine init, returns a thunk resolved at render
Interpolation Types
| Type | Behavior |
|---|---|
string / number | Inserted directly into CSS |
CSSResult (from another css call) | Recursively resolved and flattened |
Interpolation[] (array) | Each element resolved and concatenated |
(props) => Interpolation | Called at render time with { ...componentProps, theme } |
true / false | Converted to empty string (enables ${condition && css\...`}`) |
null / undefined | Converted 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)| Option | Type | Default | Description |
|---|---|---|---|
shouldForwardProp | (prop: string) => boolean | — | Control which props reach the DOM element |
boost | boolean | false | Double 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 elementshouldForwardProp
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 Pattern | Forwarded? |
|---|---|
$-prefixed (e.g., $color) | No — transient props |
as | No — consumed for polymorphism |
data-* | Yes |
aria-* | Yes |
| Known HTML attributes | Yes |
| Unknown props | No — 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 orderStatic 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):
- CSS resolved once at component creation time (module evaluation)
- Class name computed and CSS injected immediately
- Zero per-render overhead — no theme context access, no resolution
- 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:
- Theme accessed via
useTheme()on every render - CSS resolved with
{ ...componentProps, theme } - useRef cache — CSS string comparison avoids rehashing when content unchanged
useInsertionEffectinjects CSS synchronously before paint- 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
KeyframesResultwith a.nameproperty (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 nameDynamic 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
useRefcache per component instance — string comparison skips rehashing when CSS unchanged between renders - Global
StyleSheetcache bounded bymaxCacheSize(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 everythingclearCache()does, plus the SSR buffer, plus removes all rules from the DOM. Also fires registeredclearCallbacksso consumers likestyled.tsreset theirstaticComponentCacheandhotCache. Use for full HMR resets.
Benchmarks
Bundle size
Fresh build of @vitus-labs/styler (rolldown, ESM, react externalized):
| Build | Size |
|---|---|
| Bundle (unminified) | 33.97 KB |
| Minified | 12.21 KB |
| Gzipped | 4.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:
| Library | Gzipped |
|---|---|
| goober | ~1.3 KB |
| @vitus-labs/styler | 4.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
| Module | What it ships |
|---|---|
sheet.ts | StyleSheet class, SSR hydration, @media/@supports/@container rule splitting, @layer wrapping, bounded cache + eviction, prepare() cache for <style precedence>, onClear broadcast hooks |
styled.ts | static/dynamic split, hot + WeakMap component cache, LRU-2 cssText cache per dynamic render, polymorphic as prop, SSR vs client branches |
forward.ts | HTML attribute allowlist, transient ($-prefix) prop filter, shouldForwardProp option |
resolve.ts | tagged-template resolver, normalizeCSS single-pass scanner, _isDynamic / _staticResolved per-instance caches |
globalStyle.ts | createGlobalStyle (static + dynamic paths, SSR precedence) |
| Other | hash (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.
| Scenario | styler ops/s | emotion ops/s | sc 6 ops/s | styler vs best competitor |
|---|---|---|---|---|
| SSR static (500 renders/tick) | 719 | 335 | 210 | 2.14× vs emotion |
| SSR dynamic (500 renders/tick) | 434 | 307 | 202 | 1.41× vs emotion |
| SSR themed (500 renders/tick) | 363 | 286 | 184 | 1.27× vs emotion |
| CSR mount (100 mounts/tick) | 13912 | 13817 | 13702 | +1% vs emotion (tie) |
| CSR update (100 updates/tick) | 13108 | 13032 | 13239 | −1% vs sc (tie) |
| CSR many (50 distinct comps/tick) | 18933 | 18302 | 5705 | 3.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.