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.