Installation

Get StyleX compiling, then paste the token layer every component reads.

You install carbon by copying component source into your project. Two things have to exist there before any component works: the StyleX compiler and the token layer the components read.

Both fail quietly. A missing compiler leaves stylex.props() returning class names with no stylesheet behind them. A misconfigured one resolves a token to an empty variable and the declaration drops.

Setup

These steps assume a Vite + React + TypeScript project. Every file path below is relative to src/. Delete the starter's src/index.css first: it sets --border and color-scheme on :root outside any layer, and that beats carbon's values.

Install the runtime, Base UI, the fonts and the compiler.

pnpm add @stylexjs/stylex @base-ui/react clsx @fontsource/geist-sans @fontsource/geist-mono
pnpm add -D @stylexjs/unplugin

Add the plugin to your Vite config. It goes first in the array, before the React plugin, so it sees the source before anything else rewrites it.

vite.config.ts
import path from "node:path"
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
import stylex from "@stylexjs/unplugin/vite"

export default defineConfig({
  plugins: [
    stylex({
      aliases: { "@/*": [path.join(import.meta.dirname, "src/*")] },
      useCSSLayers: { before: ["carbon.base"] },
    }),
    react(),
  ],
  resolve: { tsconfigPaths: true },
  build: { cssTarget: ["chrome123", "edge123", "firefox120", "safari17.5"] },
})

Each option is required:

  • aliases lets the compiler resolve @/theme/tokens.stylex. Without it the build fails with "Could not resolve the path to the imported file".
  • useCSSLayers puts the reset layer that theme.css opens under every atomic class. Without it the reset's * { margin: 0 } beats every StyleX declaration.
  • resolve.tsconfigPaths lets Vite resolve the same @/ imports in dev.
  • build.cssTarget stops Vite's CSS minifier from rewriting light-dark(), which every themed token uses.

The plugin appends the compiled stylesheet to your CSS bundle. You do not need an @import or a virtual module.

Declare your browser floor. The compiler runs its CSS through lightningcss with your browserslist as the target, and the default targets are old enough that light-dark() gets downleveled into a polyfill that only works in production bundles. Baseline 2024 keeps it native, and matches cssTarget above.

package.json
{
  "browserslist": ["chrome >= 123", "edge >= 123", "firefox >= 120", "safari >= 17.5"]
}

Point @/* at src/. In a Vite project the compiler options live in tsconfig.app.json; the root tsconfig.json only lists references.

tsconfig.app.json
{
  "compilerOptions": {
    "paths": { "@/*": ["./src/*"] }
  }
}

In tsconfig.node.json, which type-checks vite.config.ts, set "module": "esnext" and "moduleResolution": "bundler". Under the template's nodenext, TypeScript reads the StyleX plugin's default export as a namespace and tsc -b fails with "This expression is not callable".

Copy theme.css. It holds the element reset, the color-scheme switch that drives dark mode, and the font stacks.

styles/theme.css
@import "@fontsource/geist-sans/latin.css";
@import "@fontsource/geist-mono/latin.css";

:root {
  color-scheme: light dark;

  --font-sans: "Geist Sans", ui-sans-serif, system-ui, sans-serif;
  --font-mono: "Geist Mono", ui-monospace, monospace;
}

.light {
  color-scheme: light;
}

.dark {
  color-scheme: dark;
}

@layer carbon.base {
  *,
  ::after,
  ::before,
  ::backdrop,
  ::file-selector-button {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
    border: 0 solid var(--border);
    outline-color: var(--ring-50);
  }

  html {
    line-height: 1.5;
    tab-size: 4;
    -webkit-text-size-adjust: 100%;
    -webkit-tap-highlight-color: transparent;
    font-family: var(--font-sans);
  }

  body {
    background-color: var(--background);
    color: var(--foreground);
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
  }

  h1,
  h2,
  h3,
  h4,
  h5,
  h6 {
    font-size: inherit;
    font-weight: inherit;
  }

  a {
    color: inherit;
    text-decoration: inherit;
  }

  b,
  strong {
    font-weight: bolder;
  }

  code,
  kbd,
  samp,
  pre {
    font-family: var(--font-mono);
    font-size: 1em;
  }

  small {
    font-size: 80%;
  }

  sub,
  sup {
    font-size: 75%;
    line-height: 0;
    position: relative;
    vertical-align: baseline;
  }

  sub {
    bottom: -0.25em;
  }

  sup {
    top: -0.5em;
  }

  table {
    text-indent: 0;
    border-color: inherit;
    border-collapse: collapse;
  }

  hr {
    height: 0;
    color: inherit;
    border-top-width: 1px;
  }

  ol,
  ul,
  menu {
    list-style: none;
  }

  summary {
    display: list-item;
  }

  img,
  svg,
  video,
  canvas,
  audio,
  iframe,
  embed,
  object {
    display: block;
    vertical-align: middle;
  }

  img,
  video {
    max-width: 100%;
    height: auto;
  }

  button,
  input,
  select,
  optgroup,
  textarea,
  ::file-selector-button {
    font: inherit;
    letter-spacing: inherit;
    color: inherit;
    border-radius: 0;
    background-color: transparent;
    opacity: 1;
  }

  button,
  input:where([type="button"], [type="reset"], [type="submit"]),
  ::file-selector-button {
    appearance: button;
  }

  textarea {
    resize: vertical;
  }

  ::placeholder {
    opacity: 1;
    color: color-mix(in oklab, currentcolor 50%, transparent);
  }

  :-moz-focusring:where(:not(iframe)) {
    outline: auto;
  }

  [hidden]:where(:not([hidden="until-found"])) {
    display: none !important;
  }
}

Copy the token module. Values two components share live here: themed colors are defineVars with light-dark() pairs, fixed scales are defineConsts (space, radius, text, leading, tracking, shadow, ease, duration, layer), and the interop group publishes the flat custom properties the reset reads. To retheme, edit this file or scope a stylex.createTheme(color, {…}) over a subtree.

theme/tokens.stylex.ts
import * as stylex from "@stylexjs/stylex"

export const color = stylex.defineVars({
  background: "light-dark(oklch(1 0 0), oklch(0.145 0 0))",
  foreground: "light-dark(oklch(0.145 0 0), oklch(0.985 0 0))",
  card: "light-dark(oklch(1 0 0), oklch(0.205 0 0))",
  cardForeground: "light-dark(oklch(0.145 0 0), oklch(0.985 0 0))",
  popover: "light-dark(oklch(1 0 0), oklch(0.205 0 0))",
  popoverForeground: "light-dark(oklch(0.145 0 0), oklch(0.985 0 0))",
  primary: "light-dark(oklch(0.205 0 0), oklch(0.922 0 0))",
  primaryForeground: "light-dark(oklch(0.985 0 0), oklch(0.205 0 0))",
  secondary: "light-dark(oklch(0.97 0 0), oklch(0.269 0 0))",
  secondaryForeground: "light-dark(oklch(0.205 0 0), oklch(0.985 0 0))",
  muted: "light-dark(oklch(0.97 0 0), oklch(0.269 0 0))",
  mutedForeground: "light-dark(oklch(0.556 0 0), oklch(0.708 0 0))",
  destructive: "light-dark(oklch(0.577 0.245 27.325), oklch(0.704 0.191 22.216))",
  border: "light-dark(oklch(0.922 0 0), oklch(1 0 0 / 10%))",
  input: "light-dark(oklch(0.922 0 0), oklch(1 0 0 / 15%))",
  ring: "light-dark(oklch(0.708 0 0), oklch(0.556 0 0))",

  overlay: "light-dark(oklch(0 0 0 / 10%), oklch(0 0 0 / 40%))",
  sidebar: "light-dark(oklch(0.985 0 0), oklch(0.205 0 0))",

  buttonDestructiveBg: (): string =>
    `light-dark(color-mix(in oklab, ${color.destructive} 10%, transparent), color-mix(in oklab, ${color.destructive} 20%, transparent))`,

  buttonPrimaryEdge: "light-dark(oklch(0 0 0 / 40%), oklch(0 0 0 / 10%))",
  buttonPrimaryHighlight: "light-dark(oklch(1 0 0 / 16%), oklch(1 0 0 / 60%))",
  buttonPrimarySheen: "light-dark(oklch(1 0 0 / 14%), oklch(1 0 0 / 40%))",
  buttonDestructiveHighlight: "oklch(1 0 0 / 16%)",
  buttonDrop: "light-dark(oklch(0 0 0 / 20%), oklch(0 0 0 / 50%))",
  buttonSecondaryBg: "light-dark(oklch(0.98 0 0), oklch(0.21 0 0))",
  buttonSecondaryTop: "light-dark(oklch(1 0 0), oklch(1 0 0 / 5%))",
  buttonSecondaryEdge: "light-dark(oklch(0 0 0 / 6%), oklch(0 0 0 / 36%))",
  buttonSecondaryHighlight: "light-dark(oklch(1 0 0 / 90%), oklch(1 0 0 / 7%))",
  buttonSecondaryDrop: "light-dark(oklch(0 0 0 / 4%), oklch(0 0 0 / 30%))",

  tray: "light-dark(oklch(0.985 0 0), oklch(0.17 0 0))",
  raisedHighlight: "light-dark(transparent, oklch(1 0 0 / 4%))",
  raisedShadow: "light-dark(oklch(0.2 0 0 / 6%), oklch(0 0 0 / 16%))",
  raisedRing: "light-dark(oklch(0 0 0 / 4%), oklch(0 0 0 / 40%))",
  popupRing: "light-dark(oklch(0 0 0 / 8%), oklch(0 0 0 / 50%))",
  popupShadowNear: "light-dark(oklch(0 0 0 / 6%), oklch(0 0 0 / 24%))",
  popupShadowFar: "light-dark(oklch(0 0 0 / 8%), oklch(0 0 0 / 40%))",
  itemHighlightBg: "light-dark(oklch(0 0 0 / 4%), oklch(1 0 0 / 7%))",
  itemHighlightRing: "light-dark(oklch(0 0 0 / 6%), oklch(1 0 0 / 6%))",
  invalidRing: (): string =>
    `light-dark(color-mix(in oklab, ${color.destructive} 20%, transparent), color-mix(in oklab, ${color.destructive} 40%, transparent))`,
  inputDisabledBg: (): string =>
    `light-dark(color-mix(in oklab, ${color.input} 50%, transparent), color-mix(in oklab, ${color.input} 80%, transparent))`,
  grooveShadow: "light-dark(oklch(0 0 0 / 3.5%), oklch(0 0 0 / 18%))",
  thumbShade: "oklch(0 0 0 / 3%)",
  thumbRing: "oklch(0 0 0 / 6%)",
  raisedEdge: "oklch(0 0 0 / 8%)",
  fieldHoverEdge: "light-dark(oklch(0 0 0 / 16%), oklch(1 0 0 / 14%))",
  fieldFocusEdge: "light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%))",
  fieldHalo: "light-dark(oklch(0 0 0 / 6%), oklch(1 0 0 / 12%))",
  fieldDrop: "light-dark(oklch(0 0 0 / 6%), oklch(0 0 0 / 24%))",
  fieldBg: (): string =>
    `light-dark(${color.background}, color-mix(in oklab, ${color.input} 30%, transparent))`,
  well: "light-dark(oklch(0.965 0 0), oklch(0.17 0 0))",
  wellKey: "light-dark(oklch(1 0 0), oklch(0.3 0 0))",

  calloutInfoBorder:
    "light-dark(color-mix(in oklab, oklch(68.5% 0.169 237.323) 25%, transparent), color-mix(in oklab, oklch(74.6% 0.16 232.661) 25%, transparent))",
  calloutInfoBg:
    "light-dark(color-mix(in oklab, oklch(68.5% 0.169 237.323) 6%, transparent), color-mix(in oklab, oklch(74.6% 0.16 232.661) 6%, transparent))",
  calloutInfoAccent: "light-dark(oklch(58.8% 0.158 241.966), oklch(74.6% 0.16 232.661))",
  calloutTipBorder:
    "light-dark(color-mix(in oklab, oklch(60.6% 0.25 292.717) 25%, transparent), color-mix(in oklab, oklch(70.2% 0.183 293.541) 25%, transparent))",
  calloutTipBg:
    "light-dark(color-mix(in oklab, oklch(60.6% 0.25 292.717) 6%, transparent), color-mix(in oklab, oklch(70.2% 0.183 293.541) 6%, transparent))",
  calloutTipAccent: "light-dark(oklch(54.1% 0.281 293.009), oklch(70.2% 0.183 293.541))",
  calloutWarnBorder:
    "light-dark(color-mix(in oklab, oklch(76.9% 0.188 70.08) 30%, transparent), color-mix(in oklab, oklch(82.8% 0.189 84.429) 25%, transparent))",
  calloutWarnBg:
    "light-dark(color-mix(in oklab, oklch(76.9% 0.188 70.08) 7%, transparent), color-mix(in oklab, oklch(82.8% 0.189 84.429) 6%, transparent))",
  calloutWarnAccent: "light-dark(oklch(66.6% 0.179 58.318), oklch(82.8% 0.189 84.429))",
  calloutSuccessBorder:
    "light-dark(color-mix(in oklab, oklch(69.6% 0.17 162.48) 25%, transparent), color-mix(in oklab, oklch(76.5% 0.177 163.223) 25%, transparent))",
  calloutSuccessBg:
    "light-dark(color-mix(in oklab, oklch(69.6% 0.17 162.48) 6%, transparent), color-mix(in oklab, oklch(76.5% 0.177 163.223) 6%, transparent))",
  calloutSuccessAccent: "light-dark(oklch(59.6% 0.145 163.225), oklch(76.5% 0.177 163.223))",

  transparent: "transparent",
  current: "currentColor",
})

export const interop = stylex.defineVars({
  "--background": color.background,
  "--foreground": color.foreground,
  "--border": color.border,
  "--card": color.card,
  "--muted": color.muted,
  "--muted-foreground": color.mutedForeground,
  "--primary": color.primary,
  "--ring": color.ring,
  "--ring-50": `color-mix(in oklab, ${color.ring} 50%, transparent)`,
})

export const radius = stylex.defineConsts({
  xs: "0.125rem",
  sm: "0.25rem",
  md: "0.375rem",
  lg: "0.5rem",
  xl: "0.75rem",
  xl2: "1rem",
  xl4: "1.5rem",
  full: "9999px",
})

export const shadow = stylex.defineConsts({
  xs: "0 1px 2px 0 rgb(0 0 0 / 0.05)",

  ring: `0 0 0 2px color-mix(in oklab, ${color.ring} 30%, transparent)`,
  ringInvalid: `0 0 0 2px ${color.invalidRing}`,
  rowRing: `0 0 0 2px ${color.ring}`,

  buttonPrimary: `inset 0 1px 0 ${color.buttonPrimaryHighlight}, 0 1px 2px ${color.buttonDrop}`,
  buttonDestructive: `inset 0 1px 0 ${color.buttonDestructiveHighlight}, 0 1px 2px ${color.buttonDrop}`,
  buttonSecondary: `inset 0 1px 0 ${color.buttonSecondaryHighlight}, 0 1px 1px ${color.buttonSecondaryDrop}`,

  popup: `inset 0 0 1px 1px ${color.raisedHighlight}, 0 0 0 1px ${color.popupRing}, 0 1px 2px ${color.popupShadowNear}, 0 4px 12px -2px ${color.popupShadowFar}`,
  popupLifted: `inset 0 0 1px 1px ${color.raisedHighlight}, 0 0 0 1px ${color.popupRing}, 0 2px 4px ${color.popupShadowNear}, 0 16px 40px -8px ${color.popupShadowFar}`,

  thumb: `0 0 0 0.5px ${color.thumbRing}, 0 1px 1px ${color.buttonSecondaryDrop}`,

  peekLeft: `4px 0 8px -4px ${color.popupShadowNear}, 12px 0 24px -12px ${color.popupShadowFar}`,
  peekRight: `-4px 0 8px -4px ${color.popupShadowNear}, -12px 0 24px -12px ${color.popupShadowFar}`,

  raised: `inset 0 0 1px 1px ${color.raisedHighlight}, 0 1px 2px ${color.raisedShadow}, 0 0 2px ${color.raisedEdge}, 0 0 0 1px ${color.raisedRing}`,

  field: `0 2px 2px -1px ${color.fieldDrop}`,
  fieldFocus: `0 0 0 3px ${color.fieldHalo}`,

  groove: `inset 0 1px 1px ${color.grooveShadow}`,
  well: "inset 0 1px 1.5px light-dark(oklch(0 0 0 / 6%), oklch(0 0 0 / 35%))",
  wellKey:
    "inset 0 1px 0 light-dark(transparent, oklch(1 0 0 / 8%)), 0 0 0 0.5px light-dark(oklch(0 0 0 / 10%), oklch(0 0 0 / 60%)), 0 1px 2px light-dark(oklch(0 0 0 / 8%), oklch(0 0 0 / 45%)), 0 2px 4px -1px light-dark(oklch(0 0 0 / 5%), transparent)",
})

export const ease = stylex.defineConsts({
  out: "cubic-bezier(0, 0, 0.2, 1)",
  standard: "cubic-bezier(0.4, 0, 0.2, 1)",
  decelerate: "cubic-bezier(0.32, 0.72, 0, 1)",
  tactile: "cubic-bezier(0.4, 0.36, 0, 1)",
  spring:
    "linear(0, 0.0575, 0.1802, 0.3206, 0.4549, 0.5727, 0.6709, 0.7499, 0.812, 0.86, 0.8965, 0.9239, 0.9444, 0.9596, 0.9707, 0.9789, 0.9848, 0.9891, 0.9922, 0.9944, 0.996, 0.9972, 0.998, 0.9986, 1)",
})

export const duration = stylex.defineConsts({
  fade: "100ms",
  color: "150ms",
  modal: "150ms",
  sheen: "300ms",
  move: "200ms",
  slide: "250ms",
  stackFade: "250ms",
  panel: "300ms",
  sheet: "400ms",
  drawer: "450ms",
  stack: "500ms",
})

export const layer = stylex.defineConsts({
  raised: 1,
  overlay: 10,
  sticky: 20,
  popup: 50,
})

export const font = stylex.defineConsts({
  sans: "var(--font-sans)",
  mono: "var(--font-mono)",
})

export const space = stylex.defineConsts({
  s0: "0",
  s0_5: "0.125rem",
  s1: "0.25rem",
  s1_5: "0.375rem",
  s2: "0.5rem",
  s2_5: "0.625rem",
  s3: "0.75rem",
  s3_5: "0.875rem",
  s4: "1rem",
  s5: "1.25rem",
  s6: "1.5rem",
  s7: "1.75rem",
  s8: "2rem",
  s9: "2.25rem",
  s10: "2.5rem",
  s12: "3rem",
  s16: "4rem",
  s24: "6rem",
})

export const text = stylex.defineConsts({
  xs: "0.75rem",
  compact: "0.8rem",
  code: "0.8125rem",
  sm: "0.875rem",
  base: "1rem",
  lg: "1.125rem",
  xl2: "1.5rem",
  xl4: "2.25rem",
})

export const leading = stylex.defineConsts({
  xs: "calc(1 / 0.75)",
  compact: "calc(1.125 / 0.8)",
  code: "1.75",
  sm: "calc(1.25 / 0.875)",
  base: "1.5",
  lg: "calc(1.75 / 1.125)",
  xl2: "calc(2 / 1.5)",
  xl4: "calc(2.5 / 2.25)",
})

export const tracking = stylex.defineConsts({
  tight: "-0.025em",
  widest: "0.1em",
})

Copy the breakpoints. StyleX puts a media query inside a property's value rather than around the rule, so breakpoints are constants you import.

theme/breakpoints.stylex.ts
import * as stylex from "@stylexjs/stylex"

export const breakpoints = stylex.defineConsts({
  sm: "@media (width >= 40rem)",
  md: "@media (width >= 48rem)",
  lg: "@media (width >= 64rem)",
  xl: "@media (width >= 80rem)",
  maxMd: "@media (width < 48rem)",
  maxLg: "@media (width < 64rem)",
})

Copy the markers. A part that reacts to its ancestor's state, such as an accordion's chevron when the trigger is hovered or a sidebar row's action when the row is, reads that state through a marker. StyleX only compiles markers inside a .stylex.ts file.

theme/markers.stylex.ts
import * as stylex from "@stylexjs/stylex"

export const codeBlock = stylex.defineMarker()

export const drawerPopup = stylex.defineMarker()

export const sidebarMenuItem = stylex.defineMarker()

export const accordionTrigger = stylex.defineMarker()

Copy the shared state styles. Every control shows the same focus ring, the same destructive ring when it is invalid and the same dim when it is disabled, and every popup sits on one of two surfaces, so those are written here and merged into a component's own styles.

theme/states.ts
import * as stylex from "@stylexjs/stylex"

import { color, layer, radius, shadow, space } from "./tokens.stylex"

const invalidBorder = `light-dark(${color.destructive}, color-mix(in oklab, ${color.destructive} 50%, transparent))`
const inputBg = `light-dark(transparent, color-mix(in oklab, ${color.input} 30%, transparent))`
const fieldEdge = "light-dark(oklch(0 0 0 / 10%), oklch(1 0 0 / 8%))"

export const states = stylex.create({
  focusRing: {
    borderColor: {
      default: `var(--carbon-border-color, ${color.transparent})`,
      ":focus-visible": color.ring,
    },
    boxShadow: { default: null, ":focus-visible": shadow.ring },
    outlineStyle: "none",
  },

  focusWithinRing: {
    borderColor: {
      default: `var(--carbon-border-color, ${color.transparent})`,
      ":focus-within": color.ring,
    },
    boxShadow: { default: null, ":focus-within": shadow.ring },
    outlineStyle: "none",
  },

  ring: { borderColor: color.ring, boxShadow: shadow.ring },

  rowFocusRing: {
    boxShadow: { default: null, ":focus-visible": shadow.rowRing },
    outlineStyle: "none",
  },

  invalidRing: {
    borderColor: invalidBorder,
    boxShadow: { default: shadow.ringInvalid, ":focus-visible": shadow.ringInvalid },
  },

  disabled: { opacity: 0.5, pointerEvents: "none" },

  popupSurface: {
    backgroundColor: color.popover,
    color: color.popoverForeground,
    boxShadow: shadow.popup,
    outlineStyle: "none",
  },

  itemHighlight: {
    backgroundColor: color.itemHighlightBg,
    boxShadow: `inset 0 0 0 1px ${color.itemHighlightRing}`,
  },

  itemHighlightDestructive: {
    backgroundColor: `color-mix(in oklab, ${color.destructive} 6%, transparent)`,
    boxShadow: `inset 0 0 0 1px color-mix(in oklab, ${color.destructive} 10%, transparent)`,
  },

  key: {
    "--carbon-border-color": color.buttonSecondaryEdge,
    backgroundColor: color.buttonSecondaryBg,
    backgroundImage: `linear-gradient(to bottom, ${color.buttonSecondaryTop}, transparent)`,
    backgroundOrigin: "border-box",
    "--carbon-key-surface": shadow.buttonSecondary,
  },

  keyChoice: {
    "--carbon-border-color": color.input,
    backgroundColor: inputBg,
    backgroundImage: "none",
  },

  keyChecked: {
    "--carbon-border-color": color.buttonPrimaryEdge,
    backgroundColor: color.primary,
    backgroundImage: `linear-gradient(to bottom, ${color.buttonPrimarySheen}, transparent)`,
    color: color.primaryForeground,
    "--carbon-key-surface": shadow.buttonPrimary,
  },

  keySurface: {
    boxShadow: {
      default: "var(--carbon-key-surface)",
      ":focus-visible": `var(--carbon-key-surface), ${shadow.ring}`,
    },
  },

  fieldSurface: {
    "--carbon-border-color": fieldEdge,
    borderColor: {
      default: "var(--carbon-border-color)",
      ":hover": color.fieldHoverEdge,
      ":focus-visible": color.fieldFocusEdge,
    },
    backgroundColor: { default: color.fieldBg, ":disabled": color.inputDisabledBg },
    backgroundClip: "padding-box",
    boxShadow: { default: shadow.field, ":focus-visible": `${shadow.field}, ${shadow.fieldFocus}` },
  },

  fieldFocused: {
    borderColor: color.fieldFocusEdge,
    boxShadow: `${shadow.field}, ${shadow.fieldFocus}`,
  },

  well: {
    backgroundColor: color.well,
    boxShadow: shadow.well,
  },

  wellKey: {
    "--carbon-border-color": color.transparent,
    backgroundColor: color.wellKey,
    backgroundImage: "none",
    "--carbon-key-surface": shadow.wellKey,
  },

  popupItem: {
    borderRadius: `max(0px, calc(var(--carbon-popup-radius, ${radius.lg}) - ${space.s1}))`,
  },

  iconTile: {
    display: "inline-flex",
    alignItems: "center",
    justifyContent: "center",
    flexShrink: 0,
    borderRadius: radius.lg,
    backgroundColor: color.card,
    boxShadow: `0 0 0 1px ${color.border}`,
    color: color.foreground,
  },

  trayFooter: {
    position: "relative",
    padding: `var(--carbon-tray-spacing, ${space.s3})`,
    paddingBlockStart: `calc(${radius.xl} + var(--carbon-tray-spacing, ${space.s3}))`,
    backgroundColor: color.tray,
    "::before": {
      content: "''",
      position: "absolute",
      insetInline: 0,
      insetBlockStart: `calc(-1 * ${space.s1})`,
      height: `calc(${radius.xl} + ${space.s1})`,
      borderEndStartRadius: radius.xl,
      borderEndEndRadius: radius.xl,
      backgroundColor: `var(--carbon-tray-body-bg, ${color.popover})`,
      boxShadow: shadow.raised,
      clipPath: `inset(${space.s1} 0 calc(-1 * ${space.s2}) 0)`,
      pointerEvents: "none",
    },
  },

  srOnly: {
    position: "absolute",
    width: 1,
    height: 1,
    padding: 0,
    margin: -1,
    overflow: "hidden",
    clipPath: "inset(50%)",
    whiteSpace: "nowrap",
    borderWidth: 0,
  },

  positioner: { isolation: "isolate", zIndex: layer.popup, outlineStyle: "none" },
})

Copy the state-props helper. A Base UI part hands its state to a className callback, and stylex.props() returns a style as well as a class. The helper returns both callbacks from one list of styles, so a dynamic style keeps its inline variable.

lib/state-styles.ts
import type { CSSProperties } from "react"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"
import { clsx } from "clsx"

type VarStyles = StyleXStyles<{ [key: `--${string}`]: string | number }>

type ExpressionStyles = StyleXStyles<{ zIndex: string }>

type Marker = ReturnType<typeof stylex.defineMarker>

type StyleList = ReadonlyArray<
  StyleXStyles | VarStyles | ExpressionStyles | Marker | false | null | undefined
>

type StateProps<State> = {
  className: (state: State) => string
  style: (state: State) => CSSProperties | undefined
}

type Overrides = {
  className?: string
  style?: CSSProperties
  hook?: string
}

export function stateStyles<State>(
  resolve: (state: State) => StyleList,
  { className, style, hook }: Overrides = {},
): StateProps<State> {
  return {
    className: (state) => clsx(hook, stylex.props(...resolve(state)).className, className),
    style: (state) => {
      const merged: CSSProperties = { ...stylex.props(...resolve(state)).style, ...style }
      return Object.keys(merged).length > 0 ? merged : undefined
    },
  }
}

Copy interop.css. It holds the rules StyleX cannot author: @property registrations, the scroll-driven edge masks, icon sizing inside components, and drawer gesture rules.

styles/interop.css
.no-scrollbar {
  -ms-overflow-style: none;
  scrollbar-width: none;
}

.no-scrollbar::-webkit-scrollbar {
  display: none;
}

@property --scroll-fade-t {
  syntax: "<length-percentage>";
  inherits: false;
  initial-value: 0px;
}

@property --scroll-fade-b {
  syntax: "<length-percentage>";
  inherits: false;
  initial-value: 0px;
}

@property --scroll-fade-s {
  syntax: "<length-percentage>";
  inherits: false;
  initial-value: 0px;
}

@property --scroll-fade-e {
  syntax: "<length-percentage>";
  inherits: false;
  initial-value: 0px;
}

@property --scroll-fade-mask {
  syntax: "*";
  inherits: false;
}

@keyframes scroll-fade-reveal-t {
  from {
    --scroll-fade-t: 0px;
  }
  to {
    --scroll-fade-t: var(--_scroll-fade-size-t, min(12%, 2.5rem));
  }
}

@keyframes scroll-fade-reveal-b {
  from {
    --scroll-fade-b: var(--_scroll-fade-size-b, min(12%, 2.5rem));
  }
  to {
    --scroll-fade-b: 0px;
  }
}

@keyframes scroll-fade-reveal-s {
  from {
    --scroll-fade-s: 0px;
  }
  to {
    --scroll-fade-s: var(--_scroll-fade-size-s, min(12%, 2.5rem));
  }
}

@keyframes scroll-fade-reveal-e {
  from {
    --scroll-fade-e: var(--_scroll-fade-size-e, min(12%, 2.5rem));
  }
  to {
    --scroll-fade-e: 0px;
  }
}

.scroll-fade-y {
  --_scroll-fade-size-t: var(--scroll-fade-t-size, var(--scroll-fade-size, min(12%, 2.5rem)));
  --_scroll-fade-size-b: var(--scroll-fade-b-size, var(--scroll-fade-size, min(12%, 2.5rem)));
  --scroll-fade-block: linear-gradient(
    to bottom,
    transparent 0,
    #000 var(--scroll-fade-t, 0px),
    #000 calc(100% - var(--scroll-fade-b, 0px)),
    transparent 100%
  );
  -webkit-mask-image: var(--scroll-fade-mask, var(--scroll-fade-block));
  mask-image: var(--scroll-fade-mask, var(--scroll-fade-block));
  -webkit-mask-composite: source-in;
  mask-composite: intersect;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
}

.scroll-fade-x {
  --_scroll-fade-size-s: var(--scroll-fade-s-size, var(--scroll-fade-size, min(12%, 2.5rem)));
  --_scroll-fade-size-e: var(--scroll-fade-e-size, var(--scroll-fade-size, min(12%, 2.5rem)));
  --scroll-fade-inline: linear-gradient(
    to right,
    transparent 0,
    #000 var(--scroll-fade-s, 0px),
    #000 calc(100% - var(--scroll-fade-e, 0px)),
    transparent 100%
  );
  -webkit-mask-image: var(--scroll-fade-mask, var(--scroll-fade-inline));
  mask-image: var(--scroll-fade-mask, var(--scroll-fade-inline));
  -webkit-mask-composite: source-in;
  mask-composite: intersect;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
}

.scroll-fade-x:where([dir="rtl"], [dir="rtl"] *) {
  --scroll-fade-inline: linear-gradient(
    to left,
    transparent 0,
    #000 var(--scroll-fade-s, 0px),
    #000 calc(100% - var(--scroll-fade-e, 0px)),
    transparent 100%
  );
}

@supports (animation-timeline: scroll()) {
  .scroll-fade-y {
    animation:
      scroll-fade-reveal-t 1ms ease-in-out,
      scroll-fade-reveal-b 1ms ease-in-out;
    animation-timeline: scroll(self y), scroll(self y);
    animation-range:
      0 var(--scroll-fade-reveal, 6rem),
      calc(100% - var(--scroll-fade-reveal, 6rem)) 100%;
    animation-fill-mode: both;
  }

  .scroll-fade-x {
    animation:
      scroll-fade-reveal-s 1ms ease-in-out,
      scroll-fade-reveal-e 1ms ease-in-out;
    animation-timeline: scroll(self inline), scroll(self inline);
    animation-range:
      0 var(--scroll-fade-reveal, 6rem),
      calc(100% - var(--scroll-fade-reveal, 6rem)) 100%;
    animation-fill-mode: both;
  }

  .scroll-fade-x.scroll-fade-y {
    animation:
      scroll-fade-reveal-t 1ms ease-in-out,
      scroll-fade-reveal-b 1ms ease-in-out,
      scroll-fade-reveal-s 1ms ease-in-out,
      scroll-fade-reveal-e 1ms ease-in-out;
    animation-timeline: scroll(self y), scroll(self y), scroll(self inline), scroll(self inline);
    animation-range:
      0 var(--scroll-fade-reveal, 6rem),
      calc(100% - var(--scroll-fade-reveal, 6rem)) 100%,
      0 var(--scroll-fade-reveal, 6rem),
      calc(100% - var(--scroll-fade-reveal, 6rem)) 100%;
    animation-fill-mode: both;
  }
}

@supports not (animation-timeline: scroll()) {
  .scroll-fade-y {
    --scroll-fade-t: var(--_scroll-fade-size-t);
    --scroll-fade-b: var(--_scroll-fade-size-b);
  }

  .scroll-fade-x {
    --scroll-fade-s: var(--_scroll-fade-size-s);
    --scroll-fade-e: var(--_scroll-fade-size-e);
  }
}

.scroll-fade-x.scroll-fade-y {
  -webkit-mask-image: var(--scroll-fade-block), var(--scroll-fade-inline);
  mask-image: var(--scroll-fade-block), var(--scroll-fade-inline);
}

[data-variant="outline"][aria-haspopup][aria-expanded="true"],
[data-variant="ghost"][aria-haspopup][aria-expanded="true"],
[data-variant="secondary"][aria-haspopup][aria-expanded="true"] {
  background-color: var(--carbon-menu-open-bg);
  color: var(--carbon-menu-open-fg);
}

[data-sidebar="menu-button"][aria-haspopup][aria-expanded="true"] {
  background-color: var(--carbon-menu-open-bg);
  color: var(--carbon-menu-open-fg);
}

[data-sidebar="menu-action"][aria-expanded="true"] {
  opacity: 1;
}

:where([data-slot="combobox-clear"] ~ [data-slot="combobox-trigger"]) {
  display: none;
}

[data-slot="drawer-overlay"][data-swiping] {
  transition-duration: 0s;
}

:is([data-slot="drawer-popup"], [data-slot="drawer-overlay"])[data-ending-style] {
  transition-duration: calc(var(--drawer-swipe-strength) * 400ms);
}

@media (prefers-reduced-motion: reduce) {
  :is([data-slot="drawer-popup"], [data-slot="drawer-overlay"])[data-ending-style] {
    transition-duration: 0s;
  }
}

:where([data-slot="alert"]) > svg {
  grid-column: 1;
  grid-row: 1 / span 2;
  margin-inline-end: 0.5rem;
  translate: 0 0.125rem;
}

:where([data-slot="alert-title"], [data-slot="alert-description"]) a {
  text-decoration-line: underline;
  text-underline-offset: 3px;
}

:where([data-slot="alert-title"], [data-slot="alert-description"]) a:hover {
  color: var(--foreground);
}

:where([data-slot="alert-description"]) p:not(:last-child) {
  margin-block-end: 1rem;
}

:where([data-slot="card"]) > img:first-child {
  margin-block-start: calc(-1 * var(--carbon-card-spacing));
}

:where(.carbon-icons) svg {
  width: var(--carbon-icon-size, 1rem);
  height: var(--carbon-icon-size, 1rem);
  flex-shrink: 0;
  pointer-events: none;
}

:where([data-slot="field-description"]) a {
  text-decoration-line: underline;
  text-underline-offset: 4px;
}

:where([data-slot="field-description"]) a:hover {
  color: var(--primary);
}

:where([data-slot="resizable-handle"]:is([data-separator="hover"], [data-separator="active"])) {
  color: var(--ring);
}

Import both stylesheets at the top of your entry file, theme.css first.

main.tsx
import "./styles/theme.css"
import "./styles/interop.css"

Render a component to check the setup. A failed token resolution drops the declaration without failing the build, so a successful build proves nothing.

The first vite start after installing can show an "Invalid empty selector" overlay from the StyleX plugin. Reload the page once. The production build is not affected.

Dark mode

color-scheme controls dark mode. Every themed token is a light-dark() pair, and theme.css sets color-scheme: light dark on :root, so the page follows the OS with no JS. Anything that differs between light and dark is a light-dark() pair, written once, in tokens.stylex.ts or in the one component that uses it.

A theme toggle only has to force the scheme. .light and .dark in theme.css pin color-scheme, and every token follows. Set the class before first paint or a forced theme flashes:

index.html
<script>
  ;(function () {
    try {
      var t = localStorage.getItem("theme")
      if (t === "dark" || t === "light") document.documentElement.classList.add(t)
    } catch (e) {}
  })()
</script>

The storage key is yours to choose, but it must match the one your theme switcher writes. A mismatch means the pre-paint script and the toggle disagree until the first click.

light-dark() only works on colors. A non-color value that differs by theme, such as a transform or a font-style, needs two rules: a .dark override for the forced scheme, plus a @media (prefers-color-scheme: dark) { :root:not(.light) … } twin for system mode.

Conventions

Every component takes overrides the same way:

<Button xstyle={styles.pill} />

xstyle takes StyleX styles, and the component merges it last, so it wins. style is still real inline CSS, for values only the runtime knows. className is a hook for plain CSS. It is concatenated and nothing resolves conflicts, so anything that must beat a component's own declaration goes through xstyle.

Field state

Field publishes its resolved state for plain layout parts through field-state.ts.

lib/field-state.ts
"use client"

import { createContext } from "react"
import type { Field } from "@base-ui/react/field"

export const FieldStateContext = createContext<Field.Root.State | null>(null)