Styler
High-performance CSS-in-JS engine for Vitus Labs UI System (~10.3 KB gzipped).
@vitus-labs/styler is a CSS-in-JS engine replacing styled-components. It provides a familiar tagged template literal API with static/dynamic path optimization, automatic class name hashing, and React 19 SSR support.
Installation
npm install @vitus-labs/stylerPeer dependencies: react >= 19, react-dom >= 19
Key Features
- ~10.3 KB gzipped — full-featured engine with SSR, @layer, and concurrent-mode-safe injection
- Static/dynamic split — Templates without function interpolations compute class once at definition time (zero per-render cost)
- FNV-1a hashing — Deterministic class names, automatic deduplication
- CSS-in-CSS composition — Nest
cssresults insidestyledor othercsscalls - React 19 SSR —
<style precedence>on server,useInsertionEffecton client - Transient props —
$-prefixed props consumed by styles but never forwarded to DOM - Polymorphic
asprop — Render as any element or component - Specificity boost — Double selector option for library component overrides
- @layer support — CSS cascade layer wrapping
- Bounded cache — Configurable max cache size with automatic eviction
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
All benchmarks run via Vitest bench. React is externalized in all bundle measurements.
Bundle Size
| Library | Minified | Gzipped |
|---|---|---|
| goober | 2.32 KB | 1.31 KB |
| @vitus-labs/styler | ~32 KB | 10.34 KB |
| styled-components | 44.93 KB | 17.89 KB |
| @emotion/react + styled | 48.26 KB | 16.59 KB |
styler beats styled-components by 45% and Emotion by 40% while supporting React 19 SSR, @layer wrapping, @media/@supports/@container rule splitting, specificity boost, theming, global styles, keyframes, and multi-tier render caching.
What's in the bundle
For a CSS-in-JS engine that ships SSR + bundler-style features, ~10 KB gzipped is the realistic floor. The size is driven by these source modules:
| Module | Concern |
|---|---|
sheet.ts | StyleSheet class, SSR hydration, @media/@supports/@container rule splitting, @layer support, bounded cache + eviction, prepare() cache for React 19 <style precedence>, broadcast hooks for static-cache resets on clearAll() |
forward.ts | HTML attribute allowlist, SVG element list, custom shouldForwardProp, transient ($-prefix) prop filtering |
styled.ts | Static/dynamic split, multi-tier hot+WeakMap cache, polymorphic as prop, SSR + client paths, specificity boost (.foo.foo) |
resolve.ts | Tagged template resolver, normalizeCSS single-pass scanner |
globalStyle.ts | createGlobalStyle |
| Other | hash, useCSS, keyframes, ThemeProvider, evict, css, shared, index |
If you're optimizing for the smallest possible bundle and don't need SSR or the full HTML attribute filter, goober at 1.31 KB is the minimal-feature option. styler's value is the feature set per kilobyte.
Performance (ops/sec, higher is better)
| Benchmark | styler | styled-components | @emotion | goober |
|---|---|---|---|---|
| css() creation | 25.2M | 9.0M | 2.2M | 26K |
| css() with interpolations | 24.9M | 5.6M | 2.3M | 28K |
| Template resolution | 21.4M | 3.9M | — | — |
| Dynamic interpolation | 12.4M | 13.4M | — | — |
| Nested composition | 8.3M | 2.2M | 1.4M | 8K |
| SSR renderToString | 307K | 69K | 192K | 18K |
| styled() factory | 17.3M | 109K | 933K | 18.2M |
Styler is 2.8–1034x faster than alternatives across css creation, composition, and SSR. The styled() factory is essentially tied with goober (17.3M vs 18.2M ops/s) while being 158x faster than styled-components and 18x faster than Emotion. The only benchmark where styler doesn't lead is dynamic function interpolation, where styled-components' manual flatten is ~8% faster.