gsxui

Theming

Every gsxui component is styled entirely through Tailwind utilities that resolve to a fixed set of CSS custom properties — never a literal color. Change the properties and every component restyles at once; no per-component theme prop, no rebuild of gsxui itself.

The token model

gsxui init vendors web/gsxui.css with 19 color tokens plus --radius (20 total) defined twice — once in :root for light mode, once in .darkfor dark mode (toggled by a .dark class anywhere up the tree, via Tailwind's @custom-variant dark) — and an@theme inline block that maps each one onto Tailwind's own color scale, so bg-primary, text-muted-foreground,border-input, and friends all resolve to a token, not a hard-coded value:

@theme inline {
  --radius-sm: calc(var(--radius) - 4px);
  --radius-md: calc(var(--radius) - 2px);
  --radius-lg: var(--radius);
  --radius-xl: calc(var(--radius) + 4px);
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-card: var(--card);
  --color-card-foreground: var(--card-foreground);
  --color-popover: var(--popover);
  --color-popover-foreground: var(--popover-foreground);
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
  --color-secondary: var(--secondary);
  --color-secondary-foreground: var(--secondary-foreground);
  --color-muted: var(--muted);
  --color-muted-foreground: var(--muted-foreground);
  --color-accent: var(--accent);
  --color-accent-foreground: var(--accent-foreground);
  --color-destructive: var(--destructive);
  --color-destructive-foreground: var(--destructive-foreground);
  --color-border: var(--border);
  --color-input: var(--input);
  --color-ring: var(--ring);
}

:root {
  --radius: 0.625rem;
  --background: oklch(1 0 0);
  --foreground: oklch(0% 0 0);
  --card: oklch(1 0 0);
  --card-foreground: oklch(0% 0 0);
  --popover: oklch(1 0 0);
  --popover-foreground: oklch(0% 0 0);
  --primary: oklch(0% 0 0);
  --primary-foreground: oklch(0.985 0 0);
  --secondary: oklch(0.97 0 0);
  --secondary-foreground: oklch(0.205 0 0);
  --muted: oklch(0.97 0 0);
  --muted-foreground: oklch(0.556 0 0);
  --accent: oklch(0.97 0 0);
  --accent-foreground: oklch(0.205 0 0);
  --destructive: oklch(0.577 0.245 27.325);
  --destructive-foreground: oklch(0.97 0.01 17);
  --border: oklch(0.922 0 0);
  --input: oklch(0.922 0 0);
  --ring: oklch(0.708 0 0);
}

.dark {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
  --primary: oklch(0.922 0 0);
  --primary-foreground: oklch(0.205 0 0);
  --border: oklch(1 0 0 / 10%);
  --ring: oklch(0.556 0 0);
  /* …and the rest of the pairs above, each with a dark-mode value */
}

The eight paired tokens (background/foreground,card, popover, primary,secondary, muted, accent, each with a matching -foreground, plus destructive/destructive-foreground) cover every surface + text combination a component draws; border, input, and ring cover outlines and focus rings; radiusdrives every rounded corner via the derived --radius-sm--radius-xl scale.

How to restyle

web/gsxui.css is vendored, not imported — it's yours the moment gsxui init writes it. Restyling is editing the values inside :root and .dark directly:

/* web/gsxui.css — yours after 'gsxui init'; edit the values in place */
:root {
  --radius: 0.5rem;                  /* squarer corners, every component */
  --primary: oklch(0.55 0.2 265);    /* brand blue instead of near-black */
  --primary-foreground: oklch(0.98 0 0);
}

.dark {
  --primary: oklch(0.72 0.15 265);
}

Because the variable names (--primary,--primary-foreground, …) match shadcn/ui's own convention exactly, the file is tweakcn-compatible: generate a theme attweakcn.com(or any other shadcn theme tool) and paste its :root/.dark blocks over gsxui's own — no renaming, no translation layer.

Customizing components

Caller class merge: a conflicting utility wins

Every component's fallthrough attrs can carry aclass, and it doesn't just get appended — gsx.toml'sclass_merger (vendored to ui/merge/merge.go bygsxui init, backed by tailwind-merge-go) resolves conflicts the way Tailwind itself would: whichever utility comes last in the same category wins, structural classes that aren't in that category are untouched.

<ui.Button class="h-12">Tall</ui.Button>

Button's default size class ish-9 px-4 py-2 has-[>svg]:px-3. The caller'sh-12 is in the same height category as h-9, so it drops h-9 and wins; px-4 py-2 and the structural base classes (inline-flex,items-center, rounded-md, …) survive because nothing the caller passed conflicts with them.

Attrs fallthrough: id, aria-*, data-*, hx-*

Beyond class, every attribute a caller passes that isn't one of the component's own named parameters lands on the rendered element untouched — ids, ARIA attributes, arbitrarydata-*, and HTMX's hx-* attributes all pass straight through:

<ui.Button
	id="submit"
	aria-label="Submit the form"
	data-testid="submit-btn"
	hx-post="/submit"
	hx-target="#result"
>
	Submit
</ui.Button>

Data-attribute idiom: attaching behavior to your own markup

Interactive components (dialog, dropdown, tabs, tooltip, …) don't use React's asChild/Slot pattern — gsx has no dynamic tag-swapping. Instead, each interactive component'sdata-gsxui-* attribute is its public contract, and fallthrough attrs deliver it to any element or component, no cloning and no wrapper required. A plain styledButton becomes a dialog trigger just by carrying the attribute:

<ui.Dialog>
	<ui.Button variant="outline" data-gsxui-dialog-trigger>
		Open
	</ui.Button>
	<ui.DialogContent>
		...
	</ui.DialogContent>
</ui.Dialog>

The same idiom covers every interactive component's public hooks —data-gsxui-dialog-close,data-gsxui-dropdown-trigger, and so on — see each component's page for its specific attribute names.