Lightweight CSS-in-JS library for UI packages. Battle tested on Emoji-Picker-React.
FlairUp lets package authors ship styles with their package with zero config:
it injects a <style> tag at runtime, so consumers never have to import
stylesheets or configure bundlers. It works in the browser, in Shadow DOM,
and with SSR.
npm install flairupimport { createSheet, cx } from 'flairup';
const sheet = createSheet('MyComponent');
const styles = sheet.create({
button: {
color: 'red',
':hover': {
color: 'blue',
},
},
});
const Button = () => <button className={cx(styles.button)}>Hover Me</button>;create() returns a Record mapping each scope name to a Set of class
names β one entry per scope, not a single Set:
const styles = sheet.create({
button: { color: 'red' },
title: { color: 'blue' },
});
styles.button; // Set { 'MyComponent_abc123' }
styles.title; // Set { 'MyComponent_def456' }
cx(styles.button); // "MyComponent_abc123"One atomic class is generated per CSS declaration:
- Each plain declaration (for example
color: 'red') gets its own class and its own single-declaration rule. Identical declarations are emitted once and shared across scopes. Distributive shorthands are the exception:margin: '2px'expands into one class per physical side (never logicals), multi-value forms distribute positionally, and anything that cannot be split soundly stays one atomic class β see the shorthand rules undercx()below. - Each conditional declaration (inside a pseudo selector, postcondition, or media query) gets its own class, so conditional styles compose independently.
- Each CSS variable gets its own single-declaration rule β variables are no longer grouped into one class per scope.
Property names are written in camelCase and emitted in dash-case
(backgroundColor β background-color), so both spellings share one
deduplication entry.
cx() accepts strings, arrays, Sets, and objects (truthy keys win), and
returns a single class string:
cx('a', ['b', 'c'], { d: true, e: false }); // "a b c d"
cx(styles.button, styles.title);When classes conflict, the last one passed to cx() wins β regardless of the
order the styles were created in:
const red = sheet.create({ red: { color: 'red' } });
const blue = sheet.create({ blue: { color: 'blue' } });
cx(red.red, blue.blue); // "...blue..." β blue wins
cx(blue.blue, red.red); // "...red..." β red winsConflict rules:
- Conflicts are resolved per context. The same property set globally,
under
:hover, and under a media query never conflicts with itself β each context keeps its class. - Shorthand/longhand overlap in the same context follows
cx()order per side: distributive shorthands (margin,padding,inset,gap,overflow, border widths/styles/colors,border-radius) expand into one atomic class per longhand, so a latermargin-topwins only its side while the other sides survive. Box shorthands emit only the four physical sides (they set no logicals); multi-value forms distribute positionally per CSS, and elliptical radii translate each corner to its space-separated form. Values that cannot be split soundly (any top-level barevar()/env()/attr()reference, over-count values, repeated slashes) and non-distributive shorthands (background,font,border, ...) stay one atomic class. - Each CSS variable is its own conflict domain: overriding
--tonekeeps an unrelated--spacefrom the same scope. - Classes from different contexts (pseudo selectors, media queries) are always preserved.
- Unknown classes (for example utility classes) pass through untouched, and repeated classes are deduped.
Edge cases:
- Partial overlap drops the whole earlier class. An atomic shorthand that
stays atomic (
background,font,border, an opaquevar()margin) resolves as one unit: if a later class overlaps any of its conflict keys, the earlier class is removed entirely β deterministically, regardless of creation order. That means non-overlapping declarations go with it:cx({ background: 'red url(a.png)' }, { backgroundColor: 'blue' })yields blue with no image. When those declarations matter, give each visual state its own complete scope instead of layering. borderalso resetsborder-image, soborder-image(and its longhands) belong toborder's conflict set: a laterborderreplaces an earlier image, and a later image replaces an earlierborder.!importantsurvives shorthand expansion:margin: '2px !important'still emits four longhands, each carrying the flag.
const sheet = createSheet('MyComponent');With explicit mount options:
const sheet = createSheet('MyComponent', { rootNode, nonce });Calling createSheet(name) mounts one <style id="flairup-{name}"> tag into
document.head and keeps it synchronized after every create() and
keyframes() call. getStyle() returns the sheet's CSS text, and
isApplied() reports whether a style tag is mounted.
The second argument controls where (and whether) the tag mounts:
{ rootNode }β mount under a specificHTMLElementorShadowRoot, so styles work inside Shadow DOM. A bare element may also be passed directly as the second argument.{ rootNode: null }β never mount a tag. Use this on the server (or in tests) and read the CSS withgetStyle().{ nonce }β set anonceattribute on the style tag for Content-Security-Policy environments. The nonce is also applied to an adopted server-rendered tag.
Sheets with the same name and the same mount target share one style element and its CSS state: writes from any handle are visible through every handle, and identical declarations are still emitted only once. Different names, or the same name under different roots, stay isolated.
sheet.keyframes() defines animations and returns a Record mapping each
keyframe name to its generated (hashed) animation name. Names are prefixed
with the sheet name and numbered independently of created styles:
const sheet = createSheet('Spinner');
const { spin } = sheet.keyframes({
spin: {
from: { transform: 'rotate(0deg)' },
to: { transform: 'rotate(360deg)' },
},
});
// spin === 'Spinner_0_spin'
const styles = sheet.create({
icon: {
color: 'red',
animationName: spin,
animationDuration: '1s',
animationIterationCount: 'infinite',
},
});The @keyframes rules are written into the same style element as the rest of
the sheet's CSS.
Nest scopes under a condition key to apply them only beneath a known selector:
const styles = sheet.create({
'.theme-dark': {
button: {
color: 'red',
':hover': {
color: 'blue',
},
},
},
button: {
color: 'green',
},
});Nest condition keys inside a scope to target lower level elements. All of the following prefixes are supported:
.classβ descendant class:'.menu'β.hash .menu:pseudo/::elementβ pseudo selectors and elements:':hover','::before'>/+/~β combinators:'> .icon','+ .next','~ .sibling'*β universal selector, with or without a suffix&.classβ same element plus a class, no space:'.hash.active'&:pseudoβ same element plus a pseudo selector, no space:'.hash:hover'
const styles = sheet.create({
button: {
'.menu': {
color: 'red',
},
'&.active': {
color: 'blue',
},
},
});Postconditions chain with preconditions and media queries, joining nested
levels with a space (except & conditions, which attach directly).
const styles = sheet.create({
button: {
color: 'red',
'@media (max-width: 600px)': {
color: 'blue',
},
},
});The same declaration inside and outside a media query gets distinct classes, so global and media rules never clash or collapse into each other.
const styles = sheet.create({
button: {
color: 'red',
':hover': {
color: 'blue',
},
'::before': {
content: 'π©',
},
},
});const styles = sheet.create({
button: {
'--': {
'--color': 'red',
'--hover-color': 'blue',
},
},
});Each variable produces its own single-declaration rule, so one variable can
be overridden (via cx() order) without affecting the others.
const styles = sheet.create({
button: {
'.': 'my-button', // or ["my-button", "button-main"]
color: 'red',
},
});Custom classes are added to the scope's Set as-is, alongside the generated
classes.
Render the sheet's CSS into a style tag whose id matches the sheet
(flairup-{name}). On the client, createSheet() with the same name adopts
that tag instead of mounting a duplicate, preserving the server CSS and
appending only new rules:
import { createSheet } from 'flairup';
const sheet = createSheet('MyComponent');
export function SSRStyles() {
if (sheet.isApplied()) {
return null;
}
return (
<style
id="flairup-MyComponent"
suppressHydrationWarning
dangerouslySetInnerHTML={{ __html: sheet.getStyle() }}
/>
);
}Place <SSRStyles /> anywhere inside your component. When there is no
server tag (pure client render), the sheet mounts its own tag as usual.
Given this input:
import { createSheet } from 'flairup';
const sheet = createSheet('Button', null);
const styles = sheet.create({
button: {
color: 'red',
'--': { '--bg': 'white' },
':hover': { color: 'blue' },
'@media (max-width: 600px)': { color: 'green' },
},
});
const { fade } = sheet.keyframes({
fade: { from: { opacity: '0' }, to: { opacity: '1' } },
});sheet.getStyle() returns exactly this CSS:
.Button_wqxq0q {color:red;}
.Button_x5i9n8 {--bg:white;}
.Button_-w97goy:hover {color:blue;}
@media (max-width: 600px) {
.Button_se7zu2 {color:green;}
}
@keyframes Button_0_fade {
from { opacity:0; }
to { opacity:1; }
}Each declaration produces one atomic class with one single-declaration
rule, conditional declarations get their own classes, and keyframes are
named {sheet}_{n}_{name}. Exact class hashes vary with the sheet name and
creation order.