Index · Previous: Events and forms · Next: Routing
A stylesheet and a class attribute need nothing from the framework and cost
nothing:
import './button.css';
<button class="button primary" />;
class accepts a string or a record, and the record form toggles only what
changed:
<button class= />
style accepts a string or a record, and a record may hold custom properties:
<div style=--accent />
Setting a custom property is often the cheapest possible style update: one write, no class change, no new rule.
When you want the styles next to the component:
npm install @firsthandjs/styled
import { styled, css, keyframes, createGlobalStyle, ThemeContext } from '@firsthandjs/styled';
const Button = styled.button<{ $primary?: boolean }>`
padding: 0.5rem 1rem;
border: 1px solid ${(props) => props.theme.line};
background: ${(props) => (props.$primary === true ? 'rebeccapurple' : 'transparent')};
&:hover {
filter: brightness(1.15);
}
@media (min-width: 40rem) {
padding: 0.5rem 1.5rem;
}
`;
<Button $primary onClick={save}>
Save
</Button>;
It is styled-components’ API. What differs is what a prop change costs.
The template is read once, when you write it, and each interpolation is classified by where it sits:
| Where it sits | Becomes | A change costs |
|---|---|---|
A declaration’s value — color: ${…}; |
A CSS custom property | one setProperty |
| Anywhere else — a whole block | Part of the class | a class swap |
So this produces one rule for a thousand rows with a thousand colours:
const Row = styled.div<{ $hue: number }>`
color: hsl(${(props) => props.$hue} 70% 50%);
`;
styled-components inserts a rule per distinct value, because it cannot tell that case apart. The reasoning is in ADR-0015.
const Button = styled.button<{ $on?: boolean }>`
padding: 1rem;
${(props) =>
props.$on === true &&
css`
background: purple;
color: white;
`}
`;
Two instances that resolve the same way share one rule, and switching back to a resolution already in the sheet reuses it.
A prop reaches the element if the element has it: disabled does, primary
does not. data-*, aria-*, role, style and event handlers are forwarded;
a $-prefixed prop never is.
<Button $primary disabled data-role="save" onClick={save} />
&:hover, @media, & > li — native CSS nesting, resolved by the browser.
There is no preprocessor in the package, which is most of why it is 1.97 kB
rather than 13.
const theme = signal({ line: '#d8d8e0' });
provide(ThemeContext, theme);
A theme swap is one signal write, and it updates the custom properties that
read the theme — not the components, and not rules that never mentioned it.
useTheme() reads it directly.
Give it a type once, and every interpolation is typed:
export interface AppTheme {
readonly background: string;
readonly text: string;
}
// The empty import matters: a file may only augment a module it imports.
import type {} from '@firsthandjs/styled';
declare module '@firsthandjs/styled' {
interface FirsthandTheme extends AppTheme {}
}
const Page = styled.div`
background: ${(props) => props.theme.background}; // typed;
`.backgrund` does not compile
`;
A project that declares nothing keeps props.theme['background'], so this
costs nothing to ignore.
MUI, and other React libraries, need their own provider. Each bridged
component is its own React root, so a React ThemeProvider cannot reach them
from outside; setReactWrapper puts one around every root. See
React interop.
const FancyLink = styled(Link)`
color: rebeccapurple;
`;
The wrapped component receives the class as a class prop and must put it on
its root — the same contract styled-components has with className. Every
interpolation resolves into the class there, because the element belongs to
that component; pass anything per-instance as a custom property through
style.
A styled component can be styled again, as deep as you like:
const Panel = styled.section`
background: ${(props) => props.theme.surface};
padding: 1rem;
`;
const AccentPanel = styled(Panel)`
border-left: 4px solid ${(props) => props.theme.accent};
padding: 1.25rem; // wins
`;
Both classes land on the element, and the outer declaration wins — by
specificity, not by rule order: each wrapping level repeats its own class in
the selector (.a, then .b.b). Rule order could not decide it, because a
component with a block interpolation inserts its rule when it first renders,
and a wrapper renders before what it wraps. The theme reaches every level.
const spin = keyframes`
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
`;
const GlobalStyle = createGlobalStyle`
body { background: ${(props) => props.theme.background}; }
`;
// Mount it once, anywhere under the theme.
<GlobalStyle />;
A global rule lives as long as the component that attached it, and two components attaching the same one share it until the last leaves.
A component with { shadow: true } gets a shadow root, and styles in the
document do not reach inside it. Put a <style> in the component, or use
constructable stylesheets:
const Widget = component(
() => {
return (
<>
<style>{`.box { padding: 1rem; }`}</style>
<div class="box">Encapsulated</div>
</>
);
},
{ shadow: true },
);
@firsthandjs/styled writes into the document’s head, so its rules do not
reach into a shadow root. That is a real limitation, and the reason light DOM
is the default (ADR-0007).
| Situation | Use |
|---|---|
| A design system you already have as CSS | class and a stylesheet |
| Styles that belong with one component | @firsthandjs/styled |
| A value that changes per instance | A custom property, either way |
| A component library | See Web components |
Next: Routing.