Building enterprise design systems: from design patterns to frontend architecture
Three product teams need a date picker. Each starts with the same library. A few months later, one has added date ranges, another has patched keyboard navigation, and the third has copied the component to change its styling. Now a routine upgrade means reconciling three implementations.
That is the kind of problem a design system should help with. Shared components are part of the answer, along with decisions about behavior, visual defaults, ownership, and upgrades. A Figma library or an npm package covers only part of that work.
The focus here is how to turn shared design decisions into frontend architecture that product teams can use and maintain. That means choosing patterns, designing component APIs, distributing themes, and making upgrades manageable. Examples from Arco Design, ByteDance's open-source design system, illustrate a few of those decisions.
Why share a design system?
Installing an existing component library is often a good start. Teams still need to agree on how themes are used across products and how the shared system is maintained: which variants to support, who reviews changes, how releases reach applications, and who fixes regressions.
Spend less time rebuilding familiar interactions
A new reporting workflow needs product decisions about filters, permissions, and empty states. It probably does not need a new implementation of focus trapping or a fifth version of the date picker.
Shared controls leave more time for those product decisions. Even if an experiment gets discarded, the team has spent less time rebuilding controls that other products already use.
Make common behavior dependable
A shared dialog can give every product the same focus restoration and keyboard dismissal behavior. A tested theme gives teams a consistent starting point for color contrast. Fixing either in the system lets that improvement reach all applications that adopt the release, instead of asking each team to solve the same problem.
There is a cost to standardization, too. A component API that fits a settings page may be awkward in a dense trading interface. The system needs documented ways to extend a component, so a specialized layout does not require maintaining a separate implementation.
Agree on names and defaults
Design handoffs get easier when names carry the same meaning in design files and code. A Figma property size="large" can map to <Button size="large">; color.fill.secondary can map to --color-fill-secondary. The mapping needs to be documented and maintained.
Shared interaction rules also help users move between products. Having to relearn a date format or keyboard shortcut can be more disruptive than seeing a slightly different shade of blue. Decide which behaviors should stay consistent before trying to make every screen look identical.
Roll out shared changes
A versioned token package lets teams distribute a brand refresh or dark-mode update through a dependency upgrade. Each application still needs to adopt and check the release: typography or spacing changes can break layouts even when component APIs stay the same.
Versioning, visual checks, and a clear support window belong in the plan from the start.
What belongs in the system?
Start with the pieces teams repeatedly need to agree on. Tokens and components get most of the attention, but documentation and tooling determine whether those pieces stay useful.
Design tokens
Tokens give design values names that can travel between tools and platforms. Instead of repeating #165DFF, a component refers to a role such as the primary action background.
Three layers are useful once the system needs multiple themes. Each answers a different question:
| Layer | Question | Example |
|---|---|---|
| Global | What values are available? | color.blue.600 |
| Semantic | What is this value for? | color.action.background |
| Component | Where does this component use it? | button.primary.background |
A component token can reference a semantic token, which in turn references a palette value. Changing the semantic mapping lets a theme reuse the same component styles. The token pipeline and theme example below show how those references reach CSS.
UI components
A component API should make its behavior predictable. Consumers need to know how to control its state, attach labels, handle errors, and customize its appearance without depending on internal markup.
The maintenance burden varies considerably. A Badge mostly needs styling and content rules. A Select needs keyboard handling, focus management, and selection state. A Table adds sorting, filtering, selection, and sometimes virtualization. Treating all three as entries in the same component checklist hides those differences.
Icons and assets
Icons help users recognize actions and scan dense interfaces: search, download, warning, and success should have familiar visual cues across products. A shared icon set gives teams an agreed symbol for each meaning, avoiding different icons for the same action or the same icon for unrelated actions.
Consistent viewports and stroke weights also help icons sit comfortably beside text and controls. Generate framework components from the SVG sources so teams can reuse them. For example, Arco exposes React icons through @arco-design/web-react/icon, avoiding copied SVG markup scattered through applications.
For icons that follow the surrounding text or status color, use currentColor in their SVG fills and strokes. A component can then inherit the button's color:
<Button icon={<IconSearch />}>Search</Button>
Here IconSearch comes from Arco's icon package.
Document icon meaning and labeling as well as appearance. An icon-only action needs an accessible name; a decorative icon beside a text label should not be announced a second time. Color can reinforce a status, but a label or recognizable symbol should communicate its meaning too.
Patterns that span components
A form can use the right components and still handle errors badly. Patterns describe how components work together as a user completes a task. On a failed submission, where does focus go? Is the error associated with the field? Does the page preserve what the user entered?
Searchable tables raise similar questions. Changing a filter may reset pagination, clear selected rows, or leave a batch action unavailable. These decisions belong in a documented example that product teams can adapt. A screenshot of a table does not capture them.
Documentation
An API reference tells developers which props exist. Documentation should also explain when to use a dialog instead of a drawer, how to label a destructive action, and how to recover from an error. Include layout examples, keyboard expectations, and copy that teams can reuse.
Document awkward combinations, too. A dropdown inside a modal needs rules for where its popup renders, how it stacks above the modal, and how focus moves between them. Increasing z-index alone will not resolve all three.
Tooling
Add tooling where it removes recurring manual work:
- Figma libraries and plugins: Synchronizing tokens directly between design files and Git repositories.
- Interactive documentation portal: Live sandboxes where developers can tweak props, inspect generated code, and test accessibility.
- Linting & enforcement: Custom ESLint rules to flag hardcoded colors and deprecated component props, with documented exceptions.
- Automated codemods: Migration scripts (
jscodeshift) that automatically rewrite code when breaking API changes are introduced.
Separate behavior from appearance
A design system has to answer two related questions: what should happen when someone uses a control, and how should that control look in this product? Keeping those decisions separate makes it easier to share behavior across brands without copying component implementations.
Tabs illustrate the distinction. A user switches between related views, with an active tab connected to its panel. The functional pattern defines focus movement, keyboard navigation, selection, and the relationships exposed to assistive technology. The visual treatment defines the type, spacing, colors, and active indicator. A brand refresh can change that indicator from an underline to a filled background while keeping the interaction familiar.
Functional patterns describe what users can do and how an interaction behaves. Perceptual patterns describe how it looks and feels. In code, those responsibilities often fall into three layers:
The component connects these layers through an explicit API. A styled tabs component can reuse the same behavior with different tokens; the product supplies labels and panel content. A product-specific permission check belongs in the application or a domain component, rather than becoming a special case in the shared tabs implementation.
This also gives changes a clearer review scope. A new brand color calls for theme and visual checks. A change to keyboard navigation calls for interaction and accessibility checks across all styled variants. Product teams can see which parts they may customize and which behaviors the shared implementation owns.
The layers still affect each other. A compact select may reuse the selection behavior of a spacious one, but smaller spacing changes hit targets, truncation, and the number of visible options. Separation makes these dependencies easier to reason about; the finished combination still needs testing.
Putting the pieces together
For a new system, I would start with a small package structure and one consuming application. That gives the architecture a real test before more components and tooling are added.
Package boundaries
A monorepo with pnpm workspaces is one practical option. The structure below shows how it can grow; start with tokens, styled components, and a place to document them. Add separate tooling packages as needed, and Turborepo when task caching and dependency scheduling justify it:
design-system/
├── packages/
│ ├── tokens/ # Raw JSON tokens + Style Dictionary transforms
│ ├── icons/ # Raw SVG assets + icon generation build pipeline
│ ├── primitives/ # Headless, unstyled accessible UI components
│ ├── react/ # Styled React components (@my-ds/react)
│ ├── eslint-plugin/ # Linter rules to enforce system adoption
│ └── codemods/ # Automated AST upgrade scripts
├── apps/
│ ├── docs/ # Next.js / Contentlayer documentation website
│ └── storybook/ # Component isolation & visual regression sandbox
├── package.json
├── pnpm-workspace.yaml
└── turbo.json
Why separate tokens and primitives from react?
- Platform independence:
tokenscan be consumed by iOS, Android, email templates, and web apps without pulling in React. - Explicit framework boundaries: Headless React components still depend on React. Share token data and framework-neutral state machines where practical; implement React and Vue rendering, events, refs, and lifecycle integration separately. A package boundary alone does not provide portability.
Choosing primitives
Headless primitives supply interaction behavior while leaving visual styling to the consuming application. Before building a control, check how much behavior an existing primitive can cover. Styling a dropdown usually takes less work than implementing its keyboard and focus behavior.
- Overlays and complex controls: Prefer established primitives when their behavior fits your requirements. Radix Primitives provides React components with interaction behavior; Floating UI provides positioning utilities and, in its React package, interaction hooks. Positioning alone does not implement an accessible dialog or combobox.
- Simple controls and layout: Prefer native elements for buttons and inputs, adding tokens and a focused API. Build custom behavior only when the native control cannot meet the requirement.
Decide what to own
| Approach | Good fit | Ongoing responsibility |
|---|---|---|
| Existing styled library plus a brand layer | Most workflows fit an established library | Theme integration, upgrades, and product-level accessibility |
| Headless library plus custom styles | Brand and composition need more flexibility | Styling, API adapters, and integration tests |
| Custom primitives | Documented requirements cannot be met through composition | Behavior, assistive technology testing, browser support, and maintenance |
Start with a real product workflow and record where the existing solution fails. A custom component needs a named owner and a maintenance budget, not just a different appearance.
Controlled and uncontrolled state
In controlled mode, a form control reads its current value from the parent's value prop and reports user changes through onChange. In uncontrolled mode, it manages the value internally, using defaultValue only for initialization. Supporting both is useful, but it creates edge cases around initialization, reset, and switching modes.
Keep the state contract consistent across controls. The difference is who stores the value after a user action:
In both modes, onChange can notify the application. In controlled mode, the visible value changes only after the parent supplies a new value. In uncontrolled mode, defaultValue initializes internal state once.
<Select value={department} onChange={setDepartment} />
<Select defaultValue="infra" />
These are alternative usages. Keep the chosen mode stable for the component's lifetime, and define reset behavior separately. A shared hook can enforce the contract; Arco's useMergeValue is one implementation to study.
Composing forms
Composition lets consumers arrange related parts without requiring a prop for every layout. For example, Arco exposes form fields and select options as nested components. The field definition stays beside its input, and the layout remains visible in the JSX:
<Form form={form}>
<Form.Item field="username" label="Username">
<Input />
</Form.Item>
</Form>
Building tokens
Choose one authoritative token source and generate downstream formats. A small web-only system can begin with CSS variables. When several tools or platforms need the same values, a token build makes the transformations explicit:
For cross-tool exchange, the DTCG 2025.10 format defines token types and values. It is a Community Group specification, not a W3C Standard. A dimension token is enough to illustrate the shape:
{
"space": {
"$type": "dimension",
"$value": { "value": 16, "unit": "px" }
}
}
The CSS transform can emit --space: 16px. Aliases are resolved against the complete token set; the CSS output may preserve them as variable references when configured to do so.
With Style Dictionary v5, instantiate new StyleDictionary(config) and await buildAllPlatforms(). Configure only the outputs consumers need: css/variables for web styles, and JavaScript plus companion declaration formats when code needs the values. The migration guide and format reference cover the build configuration.
Decide how themes are applied
A theme groups values for a brand, color mode, or density setting. Decide which choices are fixed for an application build and which users can change while the page is open. Compiled styles work for fixed values; CSS custom properties let components pick up runtime changes through normal CSS inheritance.
Arco illustrates this combination: Less produces stylesheets, while components also reference variables such as --color-primary-6. Its dark-mode selector can be activated with document.body.setAttribute('arco-theme', 'dark'). DesignLab adds a distribution workflow: configure theme values in an editor, publish a versioned theme package, and import it in consuming applications. The broader decision is how approved design values reach products, whether through a generated package or another token pipeline.
Document the supported combinations, such as brand plus light/dark mode, and define which layer takes precedence when values conflict. A theme update then has a reviewable scope rather than an arbitrary set of component overrides.
Follow a token through a scoped theme
The palette provides raw values, the semantic layer chooses a value for each theme, and the component consumes that role. Here is the path for a primary button:
The corresponding CSS can stay small. Palette definitions are omitted here so the theme boundary is visible:
[data-theme='light'] {
--action-bg: var(--blue-600);
}
[data-theme='dark'] {
--action-bg: var(--blue-300);
}
[data-theme] {
--button-bg: var(--action-bg);
}
.button-primary {
background: var(--button-bg);
}
Declare the component alias at each theme boundary. If --button-bg were declared only on an ancestor, it would resolve --action-bg there. Changing --action-bg in a nested theme would not update that inherited alias.
Keep overlays inside the intended theme
CSS inheritance follows the DOM, so a React portal can cross a theme boundary. A dark panel's dialog may appear under the light page body unless its portal host carries the same theme:
Choose a host inside the themed region, or apply the same theme selectors and token overrides to a host under document.body. Radix Dialog's portal container is one API for choosing that DOM location.
An in-scope host preserves token inheritance, but transformed or clipped ancestors can constrain overlays. A themed host under the body can avoid those ancestors. Test placement and theme inheritance together.
Also check what a provider actually changes. Arco's React ConfigProvider theme implementation writes colors to document.body; nesting providers does not create isolated CSS theme scopes.
Packaging for consumers
Test the published package from a consuming application. The source can look well organized while the built output pulls in every style, bundles a second copy of React, or loses its client directives.
Output formats
Prefer ESM for modern applications. Add CommonJS when a supported consumer needs it, with matching declarations for each format. Make the published contract explicit:
| Package setting | Purpose |
|---|---|
exports | Expose the JavaScript entry, its declarations, and a public CSS entry |
peerDependencies | Declare the React versions consumers must provide |
sideEffects | Preserve style imports and any JavaScript with initialization effects |
| Build externals | Keep React and its subpath imports out of the library bundle |
For example, consumers should be able to import the component and its stylesheet through stable public paths:
import { Button } from '@my-ds/react'
import '@my-ds/react/css'
Check the built output
- Preserve CSS and Less imports in
sideEffects. Audit JavaScript modules before declaring them free of side effects; registration code needs explicit treatment. - Declare React as a peer dependency and configure the build tool to externalize
react,react-dom, and their subpath imports. Peer declarations alone do not prevent bundling. - Inspect a small consumer build: named ESM imports do not guarantee that CSS is split per component.
Testing shared behavior
A regression in a shared component can affect many products. Test behavior, accessibility, and appearance:
- Unit and interaction tests (Vitest + React Testing Library): Test user events, controlled vs uncontrolled state, and keyboard shortcuts. For a labelled select, focus the trigger, press Enter, and assert that the listbox opens. Then test selection and focus restoration separately.
- Accessibility testing: Run axe checks in component tests and real browsers. The axe-core documentation notes that color-contrast checks do not work under JSDOM. Supplement automation with keyboard and screen-reader testing of complete workflows; passing an automated scan does not establish conformance.
- Visual regression testing (Playwright + Storybook): Capture pixel-level screenshots of components across states (hover, focus, disabled, dark mode) on every pull request to catch accidental CSS leakage.
Server rendering and theme initialization
Treat server rendering as a consumer requirement. Radix supports server rendering, but interactive components still need client-side event handling. In the Next.js App Router, keep client boundaries around interactive entry points instead of making the entire library a client module. Ensure the build preserves those directives.
Avoid reading window, document, or localStorage during server rendering. Derive an explicit theme from a cookie when the server can access the preference; otherwise use a framework-supported theme initialization strategy before paint. For a system preference, CSS prefers-color-scheme can provide the initial appearance. Keep the initial client render consistent with server markup, and test hard reloads for theme flashes and hydration errors.
Publish a server-rendered consumer example alongside Storybook. Include an interactive control, a themed portal, and a persisted theme preference so package and hydration regressions surface before release.
Keeping the system usable
The package can be well designed and still lose adoption. A missing variant, an unanswered issue, or an expensive upgrade gives a product team a reason to copy a component locally. Once that copy starts changing, the next system fix no longer reaches it.
Ownership and adoption
Assign each stable component an engineering owner and a design owner. Publish its maturity, supported environments, accessibility expectations, and support window. Product teams need a contribution path with a review deadline; otherwise local forks become the fastest way to ship.
Track the proportion of applications on supported versions and how long they take to upgrade. Recurring accessibility defects, local overrides, and the time users need to complete common tasks can help identify where the system needs work. Package downloads alone cannot show whether teams use the system successfully. Use these findings to prioritize fixes. Review components with no clear owner or active use case before deciding whether to maintain, replace, or retire them.
When to add a prop or feature
A request for a new prop often starts with one screen. Before adding it to the shared API, ask whether it represents a reusable component capability or a product-specific arrangement. Every prop becomes something to document, test in combination with other props, and support during upgrades.
Use the kind of change to choose the extension point:
- A visual choice such as compact density belongs in a named variant or token when its meaning is consistent across products.
- A reusable behavior, such as a clearable selection, may justify a prop with a defined state and event contract.
- A custom header, footer, or action area usually fits a slot or child component better than several booleans that rearrange the internals.
- Business rules, such as who can approve an invoice, belong in a product component composed from shared controls.
For example, showExportButton, showOwnerFilter, and showApprovalAction would turn a shared Table into a particular product's toolbar. Keep the table focused on data presentation and selection, and compose the toolbar beside it. A selection feature belongs in the table API when it needs access to the table's row state.
Ask for concrete usage examples and check whether existing composition can express them. Multiple independent consumers are useful evidence, rather than a fixed admission rule: a necessary accessibility fix should not wait for a second request. Before accepting a feature, identify its owner, valid combinations, and failure states. If combinations are difficult to explain, consider a separate component or a smaller composable part.
Preventing component divergence
Make it practical to request changes before adding rules against local alternatives:
- Linting guardrails: Write custom ESLint rules to warn developers when they use inline colors (
color: '#165DFF') instead of design tokens, with exceptions for charts, third-party integrations, and system internals. Keep native HTML available when the shared component does not fit the task. - Contribution RFC process: Provide a clear pathway for product teams to propose new components or variants. If three different products need a "Timeline" component, the core team coordinates to graduate it into the system.
Breaking changes and automated migrations
An upgrade that requires editing 40 files competes with product work. Without migration support, teams have a reasonable incentive to defer it.
Whenever breaking changes are introduced:
- Mark old APIs with
@deprecatedin TypeScript docstrings. - Publish a support window, provide compatibility shims where feasible, and pilot the migration in one consuming app.
- Ship an automated codemod script using
jscodeshift.
Show the intended API change before explaining the automation:
// Before
<Button type="danger">Delete</Button>
// After
<Button status="danger">Delete</Button>
The difficult part is deciding which usages are safe to change. A codemod should follow imports and scope rather than replace every JSX element named Button:
An import alias should still match; a local variable with the same name should not. Skip boolean attributes, expression values, unsupported namespace imports, spreads, and existing status props unless the transform explicitly handles them. Use fixtures for each case, and verify that running the migration twice produces no further changes.
Pilot the migration in one application, review the diff, and compare affected screens before rolling it out to more teams.
Design systems in the AI era
If a user can describe what they want, does an enterprise application still need all its menus, forms, and dashboards? Could the interface become a single input?
I think the input could become the starting point for many tasks. It is a good way to express a goal without first learning where the application put each feature. But expressing a goal is only part of doing the work. Users still need to inspect what the system understood, compare results, make precise changes, and decide whether to proceed.
An input starts the task; the interface makes it inspectable
Consider a request: “Find overdue invoices and prepare reminders.” A chat response could list the invoices and describe the messages. I would rather see a working surface: editable filters showing what counted as overdue, a table of matching invoices, and message previews beside the selected rows.
The input gets the user into the workflow. The table makes comparison easier; the filters expose assumptions; the preview makes the proposed action concrete. After reviewing the results, the user might switch between typing a request and manipulating controls directly. The application needs to keep both representations in sync.
That is the direction I expect: fewer fixed routes through a product, with interfaces assembled around the task at hand. Familiar controls would still matter because they let people check and refine the system's interpretation.
From a component catalog to rules for assembling a task
Today, a design system can document a table, a form, and a dialog separately. An AI-driven interface also needs rules about how those parts fit together. When should a result become a table rather than a paragraph? What must be visible before an action can be confirmed? Which changes should remain editable after a request has been interpreted?
I would make those task patterns part of the system: an interpreted request with editable assumptions, a result with its data source, an action preview, and a receipt explaining what actually changed. Patterns for waiting, interruption, partial completion, and correction become as important as loading spinners and error banners.
The architecture could let a model select and configure approved patterns, while the application supplies data, checks permissions, and executes actions. The model would have room to adapt the workflow without inventing every control or deciding its own authority. Tokens and primitives would keep these changing arrangements visually and behaviorally coherent.
Adapt the workflow without making the product unfamiliar
There is a tension here. An interface assembled for each request can be useful, but a layout that changes unpredictably makes users relearn the product. I would keep navigation, selection, editing, and confirmation conventions stable, while allowing the content and sequence of a task to adapt.
Success would mean users can recognize what the system is doing, correct its assumptions, and resume unfinished work. A useful test is to give the same task to several users and inspect where they misunderstand the generated interface, rather than judge only whether its first screenshot looks convincing.
My bet is that design systems will take on more responsibility for the structure of a task. The input may become simpler; the system behind it needs a richer vocabulary for turning intent into work people can understand and control.
Where I would start
Pick one workflow that two product teams already share, such as a searchable table with an edit form. Build it with the proposed tokens and components, then try changing its theme and upgrading its package version. Note where the product teams need local overrides or clearer documentation.
That exercise will reveal more useful work than a long component inventory. Add abstractions when the workflow calls for them, and give each one an owner who can respond when product requirements change.