Adopting Helix in an app that already has a design system (coexistence)

Helix's install guide assumes a **greenfield** app. Bringing Helix into an app that already has Tailwind, its own tokens, and global CSS — as the TimelyCare platform apps do — is the harder path. Almost every hard bug during incremental adoption comes from Helix colliding with what is already there, not from Helix itself.

This page is the human companion to the model-facing helix-setup skill's coexistence section. Read it before migrating a real app surface.

The two facts behind (almost) every coexistence bug

If you internalize these two, the specific fixes below stop looking like a disconnected checklist and start looking like one principle applied repeatedly.

  1. Helix is not hermetic — it writes to global scope in three un-namespaced ways, because it was designed to own the whole page:
    • an unlayered :root token block (hex) → collides with your app's :root tokens (§3),
    • a global * { border-color: var(--border) } rule → repaints every border in the app (§4),
    • utility classes baked into component source a fresh Tailwind install is expected to generate (§1).
  2. Helix behavior is ancestry-dependent, and every boundary you cross severs an ancestry chain. CSS inheritance, cascade-layer order, React context, z-index stacking, and flex sizing all depend on an element's ancestors. The most repeated migration symptom — "works inline, breaks in a dialog / dropdown / sheet" — is exactly this: the element left its ancestry (through a portal, a scope wrapper, or a flex parent) and everything depending on those ancestors went with it. A portal (§5) is the worst case — it severs three chains at once (CSS inheritance, React context, z-index stacking).

The fix is always the same shape: re-scope each global write Helix makes, and restore each ancestry chain a boundary severs. Everything below is that principle applied to a specific boundary.

[!NOTE] Hardened during the admin-web migration against @timelycare/helix-ui@^0.1.0, tailwindcss@^4, and @timelycare/ui@1.11.1.

Re-verified 2026-08-19 against tc-core. Everything below still applies. @timelycare/ui is still pinned to exactly 1.11.1 in admin-web, campus-web, member-web and provider-web, so §7's interim workaround is current, not stale — and all three migrations now in flight (campus-helix-migration, member-helix-coexistence, aylon/campus-helix-coexistence) carry it and will hit §7.

The one app running Helix on main is payment-web, and it is the only one with no @timelycare/ui dependency at all. That is not a coincidence: it had no second design system to coexist with, which is why it needed none of §3–§7. Read the absence of that package as the real predictor of how hard an adoption will be.


1. Register Helix's source (@source) — required, not optional

Helix is shadcn-style: its utility classes (flex, bg-sidebar, h-16, …) are baked into the component source shipped in dist/. Helix ships no compiled CSS. Tailwind v4's automatic content detection ignores node_modules by default (it skips .gitignored paths), so unless you explicitly register the Helix dist, none of those classes generate and every Helix component renders unstyled.

@import "tailwindcss";
@source "../../node_modules/@timelycare/helix-ui/dist";
@import "tw-animate-css";
@import "@timelycare/helix-ui/styles.css";

The @source path is relative to the stylesheet. Placing it between @import lines can trip Biome's noInvalidPositionAtImportRule — suppress it for that line.

2. Keep an existing Tailwind v3 config with @config

If the app still has a v3-style JS config (e.g. @timelycare/* presets), don't throw it away. Run it through the v4 engine — this is the recommended incremental path:

@import "tailwindcss";
@config "../../tailwind.config.ts";

3. Scope Helix tokens to .helix — the most important rule

The collision: the app and Helix both define the same shadcn variable names (--primary, --border, --background, …) but in incompatible formats. A typical TimelyCare app uses HSL triplets consumed as hsl(var(--token)); Helix uses hex consumed as var(--token). Helix's :root block is unlayered, so it wins globally and overwrites the app's tokens — and every existing (non-Helix) component restyles or breaks.

The fix: re-declare the app's own tokens unlayered, after the Helix import (source order keeps them global), and confine Helix's hex values to a .helix wrapper that migrated screens opt into.

@import "@timelycare/helix-ui/styles.css";

:root {
  /* the app's canonical HSL-triplet tokens, unlayered → win over Helix's unlayered :root */
}

.helix {
  /* Helix hex tokens, scoped to opted-in surfaces */
}

Then wrap each migrated surface:

<div className="helix">{/* migrated screen */}</div>

Only markup inside .helix receives Helix's token values; the rest of the app keeps its own. Migrate surface by surface — you don't have to convert the whole app at once.

4. Restore app borders (they turn black)

A direct consequence of §3. Tailwind v4's default border color is currentColor (it was gray-200 in v3), and Helix's base layer sets * { border-color: var(--border) } globally. Outside .helix, the app's --border is an HSL triplet — invalid as a bare color — so the browser falls back to currentColor, and since --foreground is #000000, borders render black.

Restore the app's border for the non-Helix scope, with class-level specificity so it beats Helix's bare * rule:

@layer base {
  *:not(.helix, .helix *) { border-color: hsl(var(--border)); }
}

5. Radix overlays escape .helix — portal them back

Dialog, Select, Sheet, and Dropdown all portal to <body>, which is outside your .helix wrapper. Two things break: overlays lose Helix tokens (an overlay's bg-foreground/50 resolves against the app's HSL-triplet --foreground → invalid), and they render underneath existing app chrome (nav, header).

Provide a body-level .helix portal node at z-index: 1300 and wire it through PortalContainerProvider. React context flows through portals, so one provider themes every overlay at once:

const [portalContainer] = useState(() => {
  if (typeof document === "undefined") return null
  const el = document.createElement("div")
  el.className = "helix"
  el.style.position = "relative"
  el.style.zIndex = "1300"
  return el
})
useEffect(() => {
  if (!portalContainer) return
  document.body.appendChild(portalContainer)
  return () => portalContainer.remove()
}, [portalContainer])

<PortalContainerProvider container={portalContainer}>
  {/* Dialog / Sheet / Dropdown / Select all inherit this container */}
</PortalContainerProvider>

Every Helix overlay already consumes usePortalContainer internally, so you set the container once. This gap recurred across two independent surfaces in the admin-web migration — treat it as mandatory. See the PortalContainer component page and the helix-app-layouts skill.

6. Unlayered legacy CSS beats layered Helix utilities

Tailwind v4 emits Helix utilities into a cascade layer, and any unlayered rule wins over a layered one regardless of specificity. So a legacy global element selector like App.css a { color: … } (unlayered) out-ranks Helix's layered text-* utility on that <a> — and the element renders the wrong color despite correct Helix classes.

Escape the selector. Change the element the legacy rule targets — e.g. render sidebar sub-items as a <button> (via asChild) instead of the default <a>:

<SidebarMenuSubButton asChild isActive={isActive}>
  <button type="button" onClick={go} className="w-full cursor-pointer text-left">
    <span>{label}</span>
  </button>
</SidebarMenuSubButton>

Alternatively, give the app's own global CSS its own cascade layer so layer order — not the "unlayered always wins" rule — decides.

7. Shared @timelycare/ui v3-era packages can block the v4 build (interim)

Still current as of 2026-08-19 — all four apps that depend on it pin exactly 1.11.1. A shared UI package (@timelycare/ui@1.11.1) @applys custom base-registered classes (typography-*, focus-outline-*) that Tailwind v4 refuses to compile, so the build fails. The interim workaround is a local package patch converting those @applys to object-spreads (e.g. '@apply typography-body-l': {}...getStyle(theme, 'body-l')).

[!WARNING] Temporary — until the upstream v4-safe @timelycare/ui ships (the ui-components-3 PR). Any app consuming a v3-era shared @timelycare/* package cannot adopt v4 until that package is v4-safe. Remove the patch once the upstream fix lands.

8. Testing under jsdom

Helix has hidden test-environment prerequisites:

  • useIsMobile / SidebarProvider call window.matchMedia, which jsdom does not implement — tests throw. Mock it (default matches: false = desktop-first).
  • Any Sidebar component must be wrapped in SidebarProvider in tests.
  • The Helix Sidebar renders its item tree once (unlike MUI's drawer, which rendered a hidden mobile copy plus a permanent desktop copy). Count-based assertions may change (e.g. drop from 2 → 1).

9. Server Components & the client boundary (requires ≥ 0.5.1) — Next.js only

[!NOTE] Skip this section on Vite. Every TimelyCare platform app is a Vite SPA, where there is no server/client boundary and every component is a client component. Nothing here applies. It is kept for a future Next.js host.

Helix marks components that need browser-only React APIs with "use client", so they establish a client boundary when imported; the rest are server-safe and render directly in a React Server Component. This split was only made correct in @timelycare/helix-ui@0.5.1 — pin at least that version.

The trap it fixes: @radix-ui/react-slot (the asChild helper, ≥ 1.2) calls React.createContext at module load with no "use client" of its own. Before 0.5.1, the Helix components that use Slot were mislabeled server-safe — so importing one into a Server Component crashed at build/render with createContext is not a function (React's server build has no createContext). On 0.5.1 those six — Button, Badge, Breadcrumb, ButtonGroup, Item, NavigationMenu — carry "use client" (they hold no state of their own, but Slot forces the boundary).

Genuinely server-safe (no "use client", safe to render directly in a Server Component): Alert, Card, Empty, FilledIcon, Input, Kbd, Logo, Pagination, Skeleton, Spinner, Textarea. Everything else establishes a client boundary — normal, and free beyond the boundary itself. You only need to wrap a Helix component in your own client component when you pass it client-only props (event handlers, etc.); the boundary itself is automatic.


Related: theme-strategy.md · tech-stack.md · the helix-setup and helix-app-layouts skills.

Last updated: 2026-07-24