Status: accepted (2026-09-19)
Applications want styled-components: styles next to the component, props feeding them, a theme, nesting, no class-name bookkeeping. The API is one people know, and there is no reason to invent a different one.
The implementation cannot be copied, though, because the model underneath is different. styled-components resolves its template during a render: props in, CSS text out, hashed, inserted, class swapped. A framework whose components run once has no render to do that in — and, more interestingly, does not need one.
styled.button, styled(Component),
css, keyframes, createGlobalStyle, a theme, and $-transient props.&:hover, @media) must work, because a library that cannot
express a hover state is not a styling library.A template is read once, when it is written, not per instance. Each interpolation is classified by where it sits, which is a property of the template and not of any prop value:
color: ${…}; — it becomes a CSS custom
property. The component has one class, shared by every instance, and a change
is element.style.setProperty('--s1a2-0', next).The classification is a scan backwards from the interpolation to the last ;,
{ or }: a colon in between means we are in a value. That is decidable from
the template alone, so it happens once.
Nesting is native CSS nesting. There is no preprocessor: the rule text goes
into the sheet as written, and the browser resolves &. Every browser this
project supports has had it since 2023.
Prop forwarding asks the element: name in element is true for disabled and
false for primary. styled-components answers the same question with a bundled
list of every valid HTML attribute.
The theme is an ordinary context holding an object, so a theme swap is one signal write, and only the custom properties that read the theme change.
integrations/interop as one rule for five swatches.setProperty call: no rule insertion, no class swap,
no selector matching beyond what the element already had.color: var(--x) instead
of color: red. Custom-property resolution is not free, but it is done by
the style engine rather than by JavaScript, and it replaces inserting a rule.styled(Component) requires the wrapped component to put its class prop on
its root. styled-components has the same requirement with className.& rewriting means one less thing between what you
wrote and what the browser got..attrs() and shouldForwardProp. Both exist in styled-components to
work around prop forwarding being a guess. Asking the element is not a guess.Restyling needed a rule about who wins. styled(Panel) puts both classes
on the element, and two single-class rules of equal specificity are decided by
which reached the stylesheet last — an order this package cannot control, since
a component with a block interpolation inserts its rule when it first renders
and a wrapper renders before what it wraps. Each wrapping level now repeats its
own class in the selector (.a, then .b.b), so the outer declaration wins by
specificity, whatever the order. The cost is a longer selector and 0.06 kB.
Two defects went with it: styled(styled(X)) threw, because class was
defined twice on the forwarded props object; and a wrapper’s class never
reached the element when it changed, because it was handed over as a string
captured once rather than as something the wrapped component could observe.
Transient ($) props are now forwarded to a wrapped component too — they are
stripped at an element, where they would be invalid attributes, but the thing
being wrapped may be a styled component that declares them.
A theme can be typed. FirsthandTheme is an empty interface in the entry
module; an application augments it and props.theme.background is typed. It
had to be declared in the entry module rather than re-exported from theme.ts,
because declare module '@firsthandjs/styled' merges with interfaces declared
there — and ThemeContext had to be annotated rather than inferred, or the
declaration file would freeze Theme as the empty-theme branch and the
augmentation would change nothing.