Frontend & design systems
A React design system for Symfony apps: tokens, CSS Modules and living next to Bootstrap
6 min read
A React design system for Symfony apps: tokens, CSS Modules and living next to Bootstrap
At Osunasport I work on the web platform behind MasMusculo, a gym-management product built with Symfony and Twig. Over time the interface had accumulated many slightly different versions of the same pieces: buttons, badges, cards, alerts. When we decided to build a proper design system, the first inventory listed around 80 entries. Most of them were not really different components, just variants of the same thing.
This post covers the decisions that made that library usable from server-rendered Symfony pages, and the less glamorous part: how a team consumes it day to day without breaking anyone's branch.
Consolidate first: from ~80 entries to ~35 components
The most valuable step happened before writing any code. The inventory had entries such as "primary pill button", "small primary button", "destructive button" or "go back button". Treated as separate components, that becomes 80 files to maintain.
Instead, variants are expressed through props. A single Button takes a variant, a size and an optional href; if href is present it renders a link instead of a button. The same idea applies to badges, alerts, avatars or calendar days. The result was roughly 35 real components organised as atoms, molecules and organisms.
All shared prop types live in one file, and nobody redefines them locally:
export type Size = 'sm' | 'md' | 'lg'
export type ButtonVariant = 'primary' | 'outline' | 'destructive' | 'ghost' | 'success'
export type AlertVariant = 'error' | 'success' | 'warning' | 'info'
A few prop rules keep the API predictable: size is always sm | md | lg, visual appearance is always variant (never type), semantic state is state, disabled is a boolean and never a variant, and content always goes through children.
Design tokens with their own prefix
Every design token is a CSS custom property with a mandatory --ds- prefix:
--ds-color-cta: #000000;
--ds-font-family: 'Example Sans', sans-serif;
--ds-radius-pill: 25px;
Bootstrap uses --bs-, so the two sets of variables can never overwrite each other. Tokens are grouped by purpose (colour, font, radius, spacing, shadow and filters for colouring SVG icons), and themes are switched with a class on <body> (dark or white), with each theme defining its own values for the same tokens.
CSS Modules, in camelCase
Component styles are CSS Modules compiled with Vite, which generates hashed class names. That gives real isolation: a class called badge inside a module can never clash with Bootstrap's global .badge. The only rule is not to use Bootstrap's global names (btn, card, modal, row, col…) directly as a className on an element.
Class names are written in camelCase (.ctaPill, .withPromo), because from TypeScript styles.ctaPill reads better and is less error-prone than styles['cta-pill']. Each module follows the same shape: a .root base class, then appearance variants, size variants and states.
Third-party libraries behind wrappers
Charts, QR scanning or date pickers come from external libraries, but they are never used directly in templates. Each one lives behind a small wrapper component that exposes only the props our use case needs and applies the design tokens internally. A capacity gauge, for example, receives a percentage and two thresholds; deciding whether it shows green, amber or red happens inside the wrapper. Small exclusive libraries go into dependencies, large ones into peerDependencies.
Mounting React components from Twig
The Symfony projects are not single-page apps, so components are mounted where the template asks for them. A single entry file keeps a COMPONENT_MAP, and the map is identical in every Symfony project that uses the library. The name in the map is the TypeScript component name and the value of the data-react-component attribute:
const COMPONENT_MAP = { Button, Badge, AlertBanner, NavCard /* ... */ }
document.querySelectorAll('[data-react-component]').forEach((el) => {
const Component = COMPONENT_MAP[el.dataset.reactComponent]
if (!Component) return console.warn('Component not found in COMPONENT_MAP')
const props = el.dataset.props ? JSON.parse(el.dataset.props) : {}
createRoot(el).render(<Component {...props} />)
})
In Twig, props are passed as JSON. One detail cost us time: the attribute must use single quotes, because Twig escapes double quotes to " and JSON.parse then fails.
<div data-react-component="Button"
data-props='{{ {"variant": "primary", "href": path("home"), "children": "Start"} | json_encode | raw }}'></div>
Versioning with semver
The library follows semantic versioning: patch for CSS fixes or token corrections, minor for new components or backwards-compatible props, and major for anything that breaks consumers, such as renaming a component, a prop or a token. Before creating any new component there is a short checklist: could an existing component cover it with one more prop? Does it need variant or state from day one? Is it exported from the barrel file and added to the COMPONENT_MAP?
Consuming it with pnpm: link or git
The library lives in its own repository, and the Symfony app can point to it in two ways:
- Local link (
link:../design-system): a symlink to a clone next to the app. Changes appear immediately, which is ideal when you are editing the design system itself. Vite rebuilds in watch mode, and Webpack Encore picks up the change through the symlink. - Git (
github:example-org/design-system#develop): pnpm downloads it. That is what teammates who only work on the app need, and what the branch is committed with, so anyone can clone and runpnpm installwithout cloning the library too.
Both cannot coexist: package.json and the lockfile reflect a single source. Two scripts switch between them:
pnpm run theme:link && pnpm install # edit the design system live
pnpm run theme:git && pnpm install # back to the committed state
Why the built dist is committed
pnpm 10 blocks dependencies installed from git from running build scripts. A prepare script that compiles on install turned out to be fragile across pnpm versions. The pragmatic solution was to commit the compiled dist in the library repository, so consumers never build anything on install. The cost is explicit: every change to the library needs a rebuild and a commit of dist.
Making the local link impossible to commit
The risk with a local link is obvious: package.json and the lockfile show up as modified, and sooner or later someone commits them. Two layers prevent that:
- Invisibility. When switching to link mode, the script marks both files with
git update-index --skip-worktree, so they disappear fromgit status. Switching back to git mode unmarks them. - A safety net. A
pre-commithook rejects any commit that contains the locallink:path. It installs itself throughcore.hooksPathfrom apreparescript, so it applies to the whole team without manual setup.
skip-worktree is per clone, so each developer who wants link mode enables it once. If a git pull complains about those two files, switching back to git mode releases them.
What I would keep
The two ideas I would repeat in any similar project are consolidating variants into props before writing components, and treating the developer workflow (link versus git, hooks, committed builds) as part of the design system itself. The components are only useful if the team can consume them without friction.