Styler
High-performance CSS-in-JS engine for Vitus Labs UI System (~3KB gzipped).
@vitus-labs/styler is a lightweight 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
- ~3KB gzipped — Minimal bundle impact
- 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()— Clear dedup cache only (keep injected CSS)sheet.clearAll()— Clear cache + ssrBuffer + remove all DOM rules
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 | 10.13 KB | 3.81 KB |
| styled-components | 44.93 KB | 17.89 KB |
| @emotion/react + styled | 48.26 KB | 16.59 KB |
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.