PRINCIPLE & CONCEPTS
Thinking in Plumeria
How to place, compose and share styles, from one component to a project split across packages.
Two facts decide most of what follows. A class name comes from the property and the value alone, so where a style is written never reaches the output. The array on classStyle is the merge, so the order you read is the order that wins. Structure has one job left, which is to be easy to read, and it can change whenever that stops being true.
Here is one component, Notice, from its first style to a project that shares its values across packages.
Start in the component's file
A style one component uses stays in that component's file and is not exported.
import * as css from '@plumeria/core';
const styles = css.create({
notice: {
display: 'flex',
flexDirection: 'column',
gap: 12,
padding: 16,
borderRadius: 8,
},
title: {
fontWeight: 600,
},
});
export const Notice = ({ title }: { title: string }) => (
<div classStyle={styles.notice}>
<strong classStyle={styles.title}>{title}</strong>
</div>
);css.create stays at the top level of the module; inside a function, the build stops. A key nothing reads stops it too, so deleting an element cannot leave its styles behind.
Compose in the order you mean
Give each concern its own key, and list the keys from the general to the specific. A pinned flag is one key and one condition:
const styles = css.create({
// ...
pinned: {
position: 'sticky',
top: 0,
},
});
<div classStyle={[styles.notice, pinned && styles.pinned]}>The right side wins, so the array answers which style overrides which. classStyle takes arrays, ternaries and conditions, so there is no helper to import and no class string to assemble.
Make a choice a lookup
When a prop picks one of several looks, give the choices a css.create of their own and index it. There is no variant configuration to learn.
const toneStyles = css.create({
info: {
color: '#0b5fff',
backgroundColor: '#eef4ff',
},
danger: {
color: '#b42318',
backgroundColor: '#fef3f2',
},
});
type NoticeProps = {
title: string;
tone?: keyof typeof toneStyles;
pinned?: boolean;
};
export const Notice = ({ title, tone = 'info', pinned }: NoticeProps) => (
<div classStyle={[styles.notice, toneStyles[tone], pinned && styles.pinned]}>
<strong classStyle={styles.title}>{title}</strong>
</div>
);The prop's type comes from the styles, so the two cannot drift apart. tone="warning" fails until a warning key exists:
Type '"warning"' is not assignable to type '"danger" | "info" | undefined'.The index compiles to a literal lookup of class strings:
<div
className={
"xxp6epoh xi0vg1kw xkhnu0f1 xqqbxt1d xyqr9vb7" + " " +
((pinned) ? "xxkkh052 x3v14h35" : "") + " " +
({"info":"xuin35ub xkex1i2c","danger":"xt5mu2nw x7tnr6ub"}[tone] || "")
}
>A default is a default parameter. A combination is one more condition in the array:
tone === 'danger' && pinned && styles.pinnedDangerLet CSS hold the state
A state the DOM already exposes needs no style prop and no branch. Put it on the element and let the style match it:
const styles = css.create({
notice: {
// ...
'[data-state="closing"]': {
opacity: 0,
},
},
});
<div data-state={state} classStyle={styles.notice}>:hover, :focus-visible, :disabled and [aria-*] work the same way. Selector rules lists what can nest in what.
A descendant reacts to its ancestor through a marker, not a path through the DOM. Combinators are rejected, so markup can change without taking a rule with it:
const styles = css.create({
notice: {
...css.marker('notice', ':hover'),
// ...
},
dismiss: {
alignSelf: 'flex-end',
opacity: 0.6,
[css.extended('notice', ':hover')]: {
opacity: 1,
},
},
});Two states in one style that can match at once and set the same property need a decision of their own, and the build asks for it. See Two states that match at once.
Pass a runtime value through a function
A value known only at runtime goes through a function key. The classes stay shared, and the value lands in a custom property on the element:
const styles = css.create({
progress: (width: string) => ({
width,
height: 2,
backgroundColor: 'currentColor',
}),
});
<div classStyle={styles.progress(`${remaining}%`)} /><div class="xjyavzpg xt4pryfr xbjfqarc" style="--x7vdqwo0-width:40%"></div>.xjyavzpg { width: var(--x7vdqwo0-width); }
.xt4pryfr { height: 2px; }
.xbjfqarc:not(#\#) { background-color: currentColor; }A runtime value in any other declaration stops the build and points here. A computed key has to be known at build time too, so a selector or a breakpoint is a literal or comes from createStatic.
Make the boundary a prop
A component takes a style the way it takes any other value: through a prop, applied last so the caller wins. It also decides what a caller may change. Notice positions itself when pinned, so a caller cannot:
type NoticeProps = {
// ...
classStyle?: css.WithoutProperties<'position' | 'top' | 'right' | 'bottom' | 'left'>;
};Style accepts any style, StyleProps only the properties it names, and WithoutProperties everything but the ones it names.
Apply a style prop on an element the component renders. Passing it on to another component stops the build, because the compiler would have to trace it through a chain of wrappers. A wrapper that only forwards styles is one to remove, so the caller styles the component that owns the element:
<Header headerStyle={[styles.base, styles.primary]} />So far:
import * as css from '@plumeria/core';
const styles = css.create({
notice: {
...css.marker('notice', ':hover'),
display: 'flex',
flexDirection: 'column',
gap: 12,
padding: 16,
borderRadius: 8,
'[data-state="closing"]': {
opacity: 0,
},
},
pinned: {
position: 'sticky',
top: 0,
},
title: {
fontWeight: 600,
},
dismiss: {
alignSelf: 'flex-end',
opacity: 0.6,
[css.extended('notice', ':hover')]: {
opacity: 1,
},
},
progress: (width: string) => ({
width,
height: 2,
backgroundColor: 'currentColor',
}),
});
const toneStyles = css.create({
info: {
color: '#0b5fff',
backgroundColor: '#eef4ff',
},
danger: {
color: '#b42318',
backgroundColor: '#fef3f2',
},
});
type NoticeProps = {
title: string;
tone?: keyof typeof toneStyles;
pinned?: boolean;
state?: 'open' | 'closing';
remaining: number;
classStyle?: css.WithoutProperties<'position' | 'top' | 'right' | 'bottom' | 'left'>;
};
export const Notice = ({
title,
tone = 'info',
pinned,
state = 'open',
remaining,
classStyle,
}: NoticeProps) => (
<div
data-state={state}
classStyle={[
styles.notice,
toneStyles[tone],
pinned && styles.pinned,
classStyle,
]}
>
<strong classStyle={styles.title}>{title}</strong>
<button classStyle={styles.dismiss}>Dismiss</button>
<div classStyle={styles.progress(`${remaining}%`)} />
</div>
);Keep values apart from styles
Notice spells out its colors, so a dark theme would mean a branch in every component like it. Move the values into a file of their own. createTheme holds what changes with a selector or a query:
import * as css from '@plumeria/core';
export const theme = css.createTheme('.dark', {
info: {
default: '#0b5fff',
theme: '#8ab4ff',
},
infoSurface: {
default: '#eef4ff',
theme: '#10203f',
},
});import { theme } from '../styles/theme';
const toneStyles = css.create({
info: {
color: theme.info,
backgroundColor: theme.infoSurface,
},
// ...
});The style now reads a variable, so Notice has no dark-mode branch and needs no provider:
:where(:root) {
--x4iemmlw-info: #0b5fff;
--x2cyw8xi-info-surface: #eef4ff;
}
.dark {
--x4iemmlw-info: #8ab4ff;
--x2cyw8xi-info-surface: #10203f;
}
.xq5fprsm { color: var(--x4iemmlw-info); }
.xshf6edi:not(#\#) { background-color: var(--x2cyw8xi-info-surface); }createStatic holds what never changes, such as a breakpoint, and is written in place wherever it is read. @plumeria/wind carries Tailwind's design values the same way.
import * as css from '@plumeria/core';
export const breakpoints = css.createStatic({
sm: '@media (max-width: 640px)',
});Rename a value and every read of it stops the build until it follows:
Error: [plumeria] Unknown style member: theme.info does not exist.Move a style when a second component needs it
Notice stacks its children, and now Card has to stack its own the same way. A style used by more than one file moves to a module, and every file that needs it imports it:
import * as css from '@plumeria/core';
export const layout = css.create({
stack: {
display: 'flex',
flexDirection: 'column',
gap: 12,
},
});import type { ReactNode } from 'react';
import { layout } from '../styles/layout';
export const Card = ({ children }: { children: ReactNode }) => (
<article classStyle={layout.stack}>{children}</article>
);styles/ only exports. It imports no components and renders nothing, so dependencies run one way: styles/ ← components/ ← app/.
app/
└─ page.tsx
components/
├─ Card.tsx
└─ Notice.tsx
styles/
├─ layout.ts
├─ theme.ts
└─ tokens.tsStyles that crowd a component's markup can move to a styles.ts beside it. Only that component imports the file, until a second one needs a style from it.
Refactoring is free
That move changed nothing the browser receives. A class name comes from the property and the value alone, so Notice ends up with the classes it started with:
const styles = css.create({
notice: {
display: 'flex',
flexDirection: 'column',
gap: 12,
padding: 16,
borderRadius: 8,
},
});
<div classStyle={styles.notice}>
<div classStyle={[layout.stack, styles.notice]}><div className={"xxp6epoh xi0vg1kw xkhnu0f1 xqqbxt1d xyqr9vb7"}>The same holds for splitting a module or merging two of them, so the structure can follow the code as it grows.
Imports
Every read of a definition is written in place, and the rules for imports follow from that.
A file that only writes classStyle needs no import of @plumeria/core. A file that hands a style to a component through any other prop does, because that import is how the compiler finds the file:
× @plumeria(custom-props-require-import): bodyStyle passes a style from
"../styles/layout", so this file must import "@plumeria/core".
╭─[components/Panel.tsx:4:36]
3 │
4 │ export const Panel = () => <Notice bodyStyle={layout.stack} />;
· ────────────────────────
╰────Keeping the import in every file that uses Plumeria is always safe, and it is the fastest path: the compiler accepts such a file without looking any further.
A namespace import reads a definition by name, as a named import does:
import * as css from '@plumeria/core';
import * as tokens from './tokens';
export const layout = css.create({
stack: {
display: 'flex',
flexDirection: 'column',
gap: 12,
[tokens.breakpoints.sm]: {
gap: 8,
},
},
});The compiler has to see which definition is read, so a string key, destructuring, a rest element or passing tokens as a whole stops the build:
Error: [plumeria] tokens.breakpoints reads a Plumeria definition through a
namespace import, which cannot be compiled. Import it by name:
import { breakpoints } from '...'.An import whose names are all compiled away is removed from the output, so the bundler never loads the module through it. An import still read at runtime, rendered as a component or re-exported stays. A module that also has to run on import, such as one importing a global stylesheet, gets an import of its own:
import './tokens';Across packages
When a second app needs the same values, they move to a package. The package points a plumeria export condition at its TypeScript source:
{
"name": "@acme/tokens",
"exports": {
".": {
"plumeria": "./src/index.ts",
"types": "./src/index.ts"
}
}
}import * as css from '@plumeria/core';
export const breakpoints = css.createStatic({
sm: '@media (max-width: 640px)',
});An app that lists the package in its dependencies reads it like a local module, so only the import line changes:
import * as css from '@plumeria/core';
import { breakpoints } from '@acme/tokens';
const styles = css.create({
notice: {
padding: 16,
[breakpoints.sm]: {
padding: 12,
},
},
});The compiler follows the condition through subpath and pattern exports such as @acme/tokens/colors, and into packages linked from a pnpm store. dependencies, devDependencies, peerDependencies and optionalDependencies all count. A package without the condition is not read, and a definition taken from it stops the build.
The import compiles away, so a package that exports only definitions can ship its source alone, as @plumeria/wind does. One that also exports runtime code keeps a default condition for it.