Skip to content

ADR-0003: Migrate styling from styled-components to Fluent UI v9

Status: Accepted

Context

A brochure marketing site's UI was hand-rolled: thousands of lines of styled-components CSS across many files, with every control (inputs, selects, mobile nav drawer, desktop hover submenus, breadcrumbs, hero carousel) built from raw HTML plus bespoke CSS. Accessibility, focus management, keyboard behaviour, and touch targets were all maintained by hand — e.g. the mobile nav implemented its own focus trap, scroll lock, and Escape handling; the contact form wired aria-invalid, hint text, and error styling manually.

Three options were on the table: adopt Fluent UI v9 wholesale, cherry-pick accessible primitives (e.g. Radix / Headless UI) while keeping styled-components for layout, or keep the hand-rolled approach and only fix specific a11y gaps. Fluent UI v9 was chosen because it was React 19-compatible, ships the controls the site needed (drawer, menu, carousel, breadcrumb) as one dependency tree, and has a token-based theme system that can be pointed at an existing brand colour.

Decision

Migrate the whole site from styled-components to Fluent UI v9 (@fluentui/react-components, styled via Griffel makeStyles/makeStaticStyles), in phases (foundation → form → navigation → layout/pages → carousel → cleanup).

Three further calls within that decision:

  1. Custom brand theme, not Fluent's stock look. A 16-stop BrandVariants ramp was derived from Fluent's default brand ramp lightness/saturation curve, anchored so a mid stop matches the existing brand purple. Fluent's Theme Designer has no scriptable API, so the ramp is a principled reconstruction rather than a tool export; contrast against white was preserved by reusing the same brand colour.
  2. Full migration, not a permanent hybrid. styled-components is removed entirely by the end, rather than cherry-picking Fluent components onto a styled-components base indefinitely. A hybrid would mean two CSS-in-JS runtimes and two SSR style-collection pipelines permanently, for no lasting benefit — a dual pipeline in the document shell is only a temporary bridge during migration.
  3. Fluent Carousel replaces a third-party slider. Drops slider-specific dependencies and CSS overrides, in exchange for re-verifying prefers-reduced-motion and touch targets against Fluent's carousel primitives.

Consequences

  • Accessibility behaviour that was hand-maintained (focus trap, Escape handling, form field aria-describedby wiring) moves to tested library code — drawer, accordion, and field primitives specifically.
  • Deliberate non-conversions, to avoid regressions:
    • Headings stay semantic <h1>–<h3> with a fluid clamp() type scale rather than Fluent's fixed-px title components.
    • Some form controls stay native elements (e.g. <select> via Fluent's Select) rather than custom listboxes that break native mobile pickers and existing e2e interactions.
    • Desktop main-nav hover submenus can stay native CSS hover/focus-within reveal when the trigger is a real routed link, not only a disclosure toggle — Fluent Menu's composite keyboard model would change navigation behaviour for little a11y gain.
    • Breadcrumb trails and always-visible inline sub-navs can stay semantic native markup when Fluent drawer/nav primitives would fight the design; structured data (e.g. BreadcrumbList JSON-LD) stays separate from presentation.
  • Fluent UI's focus-management library can plant hidden tab-order "bumper" sentinels site-wide once any focus-trap component mounts. Those nodes can trip axe-core's aria-hidden-focus rule as a known library false positive — exclude the sentinel selector in a11y scans rather than disabling the rule outright.
  • Bundle size is expected to grow — Fluent v9 is larger than styled-components for a static brochure site. Measure First Load JS at a baseline and again after cleanup; a large regression is a reason to reconsider specific components rather than the whole migration.
  • Prefer px / clamp() / Fluent tokens in component styles after dropping legacy root font-size hacks used only to make rem math nicer.

Frontend Corner — agent ops, tooling, and decision records