Skip to content

shadcn/ui

Render GistUI answers with your shadcn/ui components in React - Card, Button, Input, Textarea, Select and Checkbox - with forms, validation, actions and dialogs still working.

Updated View as Markdown

For React apps built with shadcn/ui and Tailwind. A complete, tested version of this page is in the repository: examples/react-shadcn. The repository’s bun run test:shadcn checks that these components behave like the built-in ones.

npm i @gistui/react @gistui/styles @gistui/catalog

1. Order the CSS

GistUI’s stylesheet must come before Tailwind, so the cascade layers are theme, base, gistui, components, utilities: above Tailwind’s reset, below its utilities.

main.tsxts
import "@gistui/styles/styles.css";
import "./index.css"; // @import "tailwindcss"; and the shadcn theme

2. Match the theme with tokens

shadcn/ui themes are CSS variables. Point GistUI’s tokens at them and every built-in component follows your theme, light and dark.

import type { GistUITokens } from "@gistui/react";

export const tokens: GistUITokens = {
  font: "inherit",
  radius: 10,
  bg: "var(--background)",
  surface: "var(--card)",
  surfaceSunk: "var(--muted)",
  surfaceHover: "var(--accent)",
  fg: "var(--foreground)",
  fgMuted: "var(--muted-foreground)",
  border: "var(--border)",
  borderStrong: "var(--input)",
  primary: "var(--primary)",
  primaryFg: "var(--primary-foreground)",
  accent: "var(--primary)",
  accentFg: "var(--primary-foreground)",
  danger: "var(--destructive)",
  chart: ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)"],
};

3. Map components

gistui-shadcn.tsxtsx
import { useId } from "react";
import type { ComponentProps } from "@gistui/react";
import { IconSvg, ui, useGistButton, useGistField } from "@gistui/react/ui";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";

const str = (v: unknown) => (typeof v === "string" && v !== "" ? v : undefined);

function ShadcnCard({ props, children }: ComponentProps) {
  const v = str(props.v) ?? "card";
  return (
    <Card data-gistui="Card" className={cn("gap-0 py-0", v === "sunk" && "border-0 bg-muted shadow-none", v === "outline" && "shadow-none")}>
      <CardContent className="flex flex-col gap-4 p-6">{children}</CardContent>
    </Card>
  );
}

const VARIANTS = { primary: "default", accent: "default", secondary: "outline", ghost: "ghost", link: "link", danger: "destructive" } as const;

function ShadcnButton(p: ComponentProps) {
  const b = useGistButton(p); // do: actions, submit and draft in a Form, opens: a Dialog, links
  const variant = VARIANTS[b.variant as keyof typeof VARIANTS] ?? "default";
  const inner = <>{b.icon && <IconSvg name={b.icon} />}{!b.iconOnly && b.label}</>;
  if (b.href) {
    return <Button asChild variant={variant}><a href={b.href} target="_blank" rel="noopener noreferrer">{inner}</a></Button>;
  }
  return (
    <>
      <Button variant={variant} size={b.iconOnly ? "icon" : "default"} className={cn(b.full && "w-full")} {...b.buttonProps}>{inner}</Button>
      {b.dialog}
    </>
  );
}

function ShadcnInput(p: ComponentProps) {
  const id = useId();
  const field = useGistField(p); // validation, error, bind:$var
  const value = field.bound
    ? { value: field.value == null ? "" : String(field.value), onChange: (e: React.ChangeEvent<HTMLInputElement>) => field.setValue(e.target.value) }
    : { defaultValue: str(p.props.value) };
  return (
    <div className="grid gap-2">
      {field.label && <Label htmlFor={id}>{field.label}{field.required && <span className="text-destructive">*</span>}</Label>}
      <Input id={id} name={field.name} type={str(p.props.type) ?? "text"} placeholder={str(p.props.placeholder)} disabled={field.locked} aria-invalid={field.error ? true : undefined} {...value} />
      {field.error && <p role="alert" className="text-sm text-destructive">{field.error}</p>}
    </div>
  );
}

function ShadcnCheckbox(p: ComponentProps) {
  const id = useId();
  const field = useGistField(p, "bool");
  const value = field.bound
    ? { checked: field.value === true, onCheckedChange: (c: boolean | "indeterminate") => field.setValue(c === true) }
    : { defaultChecked: p.props.checked === true };
  return (
    <div className="flex items-center gap-2">
      {/* With `name`, Radix renders a hidden checkbox input that the Form reads. */}
      <Checkbox id={id} name={field.name} disabled={field.locked} {...value} />
      <Label htmlFor={id}>{field.label}</Label>
    </div>
  );
}

export const shadcnUI = ui.extend({ Card: ShadcnCard, Button: ShadcnButton, Input: ShadcnInput, Checkbox: ShadcnCheckbox });

The example in the repository also maps TextArea and Select (shadcn’s Select picks one value, so it hands multiple back to GistUI’s own).

4. Render with it

import { GistUI } from "@gistui/react";
import { shadcnUI } from "./gistui-shadcn";
import { tokens } from "./tokens";

<GistUI library={shadcnUI} tokens={tokens} stream={stream} onAction={handle} />;

Notes

  • useGistField(p, "bool") is for one checkbox or switch; "list" for a multi-value control; the default "text" for everything else.
  • Keep data-gistui="Card" on your wrapper if you rely on GistUI’s style hooks.
  • Components you do not map (Chart, Table, DatePicker, Dialog…) stay GistUI’s and take your colours from the tokens.
Navigation

Type to search…

↑↓ navigate↵ selectEsc close