Design System Lessons From A Creator Video Startup

Building a design system at a live-video creator platform with Atomic CSS, semantic tokens, and a versioning strategy that survived a partially migrated codebase. The mistakes I'd avoid next time.

A Friday afternoon at the live-video creator platform I led engineering at. Five PRs merged in one batch, finishing a chunk of the Atomic CSS migration. CI green. Deploy ran. About 30 minutes later, support pinged. Creator profile pages were missing the bio section. The bio was still in the DOM, but a global reset that the new design system bundle quietly emitted was nuking padding and margin on every <section> tag on the page. Visible on every creator profile. Twitter started noticing.

I’d introduced Atomic CSS and the shared design system over the previous quarter, replacing a patchwork of styled-components and per-feature CSS modules. We were about 70% migrated. That last 30% was a long tail of older Vue components on the creator profile and discovery surface. That’s exactly where the bomb landed.

Tokens Come Before Components

The mistake most teams make is starting with a Button component and hardcoding colors. We did it. color: #5b21b6 scattered across 40 components. When the brand call came down to shift the primary hue, the diff touched every component in the repo.

Tokens are the contract. Build them first, in three layers.

{
  "primitive": {
    "violet": {
      "50":  { "$value": "#f5f3ff" },
      "500": { "$value": "#5b21b6" },
      "600": { "$value": "#4c1d95" }
    },
    "neutral": {
      "0":    { "$value": "#ffffff" },
      "900":  { "$value": "#111827" },
      "1000": { "$value": "#000000" }
    }
  },
  "semantic": {
    "color": {
      "bg-primary":        { "$value": "{primitive.neutral.0}" },
      "text-primary":      { "$value": "{primitive.neutral.900}" },
      "interactive":       { "$value": "{primitive.violet.500}" },
      "interactive-hover": { "$value": "{primitive.violet.600}" }
    }
  },
  "component": {
    "button": {
      "primary-bg":    { "$value": "{semantic.color.interactive}" },
      "primary-hover": { "$value": "{semantic.color.interactive-hover}" },
      "primary-text":  { "$value": "{primitive.neutral.0}" }
    }
  }
}

Primitives never appear in component code. Components reference semantic tokens. Semantic tokens reference primitives. When a rebrand lands, you change values in the primitive layer and the rest propagates for free. Theming is a semantic layer swap, not a refactor.

I run this through Style Dictionary to emit CSS variables, a TypeScript constants module, and an Atomic CSS preset from a single source.

// style-dictionary.config.js
module.exports = {
  source: ['tokens/**/*.json'],
  platforms: {
    css: {
      transformGroup: 'css',
      buildPath: 'dist/css/',
      files: [{
        destination: 'variables.css',
        format: 'css/variables',
        options: { outputReferences: true },
      }],
    },
    ts: {
      transformGroup: 'js',
      buildPath: 'dist/ts/',
      files: [{ destination: 'tokens.ts', format: 'javascript/es6' }],
    },
  },
};

The outputReferences: true flag matters. It preserves the alias chain so --button-primary-bg resolves to var(--color-interactive) in the browser, not a baked hex. You can inspect the relationship in DevTools. Saved me hours when a designer asked why a hover state looked off three layers deep.

Atomic CSS Without The Foot-gun

Atomic CSS for us wasn’t Tailwind. We had our own conventions, but the shape was similar. Utility classes mapped to tokens. Components composed utilities. The bet was that compose-time CSS would scale better than runtime styled-components, especially with Vue’s SFC scoping doing weird things at the edges.

Here’s the part nobody warns you about. When you’re 70% migrated and the new bundle ships a global reset, that reset hits the 30% that isn’t migrated. We hit that wall.

The bio outage, in beats

Setting. The creator-video startup. Friday afternoon merge. Five PRs migrating older Vue components in one batch. Past the easy components and into the long tail.

What went wrong. About 30 minutes after deploy, support pinged. Creator profile pages were missing the bio. The new design system bundle quietly emitted a global CSS reset that zeroed padding-top and margin on every <section> tag. Bio container collapsed to zero height. Visible on roughly every creator profile we had.

First wrong fix. A teammate pushed a hotfix adding a specific padding-top override on the bio. Fixed the bio. Within an hour, three more reports came in for similarly collapsed sections elsewhere. The reset was hitting layouts we hadn’t traced.

Real fix. Rolled the design system bundle back to the previous version. Re-extracted only the actual atomic class additions from the original five PRs. Audited the reset, scoped it to a data attribute boundary on the new surface rather than emitting it globally. Re-shipped two days later.

Cost. Two hours of visibly broken creator profile pages on a high traffic Friday. A handful of creators replied to our public Twitter asking what was going on. After that I added Chromatic snapshots against the top 50 most visited routes. Cheap compared to another public break.

Component APIs That Don’t Trap You

We shipped a fair set of components over the migration. The API decisions made in the first month shaped everything after.

The polymorphic as prop is the prettiest trap in the playbook. It looks flexible. You write one Button and it renders as <a>, <button>, or a router link based on as. The trouble is the TypeScript. href should only be valid when as="a". to only when as is a router link. Getting that right with generics is a part-time job, and the autocompletion still fails in edge cases. I’d rather split it.

// Boring, but correct
<Button type="submit">Submit</Button>
<ButtonLink href="/dashboard">Go to Dashboard</ButtonLink>
<ButtonRouterLink to="/settings">Settings</ButtonRouterLink>

Three components, tight types, wrong usage is a compile error. The cost is three exports instead of one. The win is no runtime surprises and IDE help that actually helps.

For compound components like Tabs and Accordion, supporting both controlled and uncontrolled modes is non negotiable. Half your consumers want to drive state from a URL param. The other half want it to just work.

interface TabsProps {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  children: ReactNode;
}

export function Tabs({ value, defaultValue, onValueChange, children }: TabsProps) {
  const [internal, setInternal] = useState(defaultValue ?? '');
  const isControlled = value !== undefined;
  const current = isControlled ? value : internal;

  const handleChange = useCallback((next: string) => {
    if (!isControlled) setInternal(next);
    onValueChange?.(next);
  }, [isControlled, onValueChange]);

  return (
    <TabsContext.Provider value={{ value: current, onChange: handleChange }}>
      <div role="tablist">{children}</div>
    </TabsContext.Provider>
  );
}

If you force one pattern, half your users build a wrapper around your component. That wrapper becomes the de facto API and you don’t own it anymore.

Versioning On A Partially Migrated Codebase

We followed semver. Major for breaking changes, minor for new components, patch for bug fixes. Clean rules, until they aren’t.

In one patch release we fixed an a11y bug on the Select component. The dropdown wasn’t getting focus when opened via keyboard. We added autoFocus to the listbox. Correct per WAI-ARIA. Two product squads had built custom focus logic on top of our Select, and the fix made focus jump twice. Their test suites broke. A patch release broke production for two squads.

Technically we were right. Practically, any behavioral change that existing code depends on is breaking, regardless of what semver says it is.

What changed after that. Any behavioral change ships as minor, even if the commit is tagged fix. The only patches are doc typos and dep bumps. Risky changes go out as canary first.

npm publish --tag canary
# squads opt in for a release cycle before it hits latest
npm install @platform/design-system@canary

And when we did ship a real major, we shipped a codemod with it. Teams that would have stalled the upgrade for months did it in an afternoon.

What I’d Do Differently

Start with fewer components. We launched with too many. Half of them had API issues that needed breaking changes inside two months. A small set done right beats a large set done shaky.

Don’t rebuild what Radix already solved. We hand rolled a Combobox once and it took weeks to get the focus trap, scroll lock, and portal behavior right. Headless libraries for the gnarly interactive patterns, your styled layer on top. Save the engineering time for the components your product genuinely needs to be different at.

Treat the design system like a product, not a project. Dedicated backlog, sprint reviews with consuming teams, a Slack channel for requests. The moment you stop maintaining it actively, teams start building around it instead of with it. We saw that drift start around the fifth month and had to actively pull it back.

I’ll add one more from a different war. Years later at the creator economy platform I worked at, I lived through an Aurora reader replica blowing up because of an analyze running on the wrong table at the wrong time. Different stack, same shape of bug. The thing you ship that quietly affects everything is the thing that bites you. A global reset, a maintenance job, a focus rule. Boundary the blast radius before you ship.

Takeaways

  • Tokens first, in three layers. Primitive, semantic, component. Never reference primitives directly from components.
  • A global reset in a partially migrated codebase is a foot-gun. Scope it with a boundary attribute.
  • Skip the polymorphic as prop. Ship separate components and let TypeScript help.
  • Compound components must support controlled and uncontrolled both. Pick one and you’ll lose half your consumers.
  • Any behavioral change is breaking. Use minor versions liberally, ship canaries for risky work, ship codemods with majors.
  • Visual regression CI is cheap. Public breaks are not.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.