Skip to content
ThorbisInterface Guidelines (opens in a new tab)
All components

Button

Commits, chips, quiet actions, and icon-only controls from one stateful primitive.

Adopted
Family
Action
Available import
@/components/ui/button
Status
Thorbis wrapper and live specimen ship here.
Live Thorbis specimenbutton
Input referenceUnavailable link
Use
Use the default ink button for a commit, chip for a reversible filter, ghost for quiet utility, and icon only when the mark is unambiguous.
Avoid
Do not make every action a pill, remove the word while busy, or invent page-local button colors.

Use this component

GitHub source links require access to the private Thorbis design repository.

Wrapper sourceRepository and dependencies

Value exports
  • Button
  • buttonVariants
Type exports
  • ButtonGroupDensity
  • ButtonProps
  • ButtonSize
  • ButtonVariant
Setup, styles, and dependencies

These are React wrappers in the Thorbis design repository, not an installable npm UI package. In this repository, @/ resolves to src/ through tsconfig.json.

Use the repository's package.json and package-lock.json for dependencies. Load src/styles.css once at the application root for global tokens, typography, and themes. The wrappers compose colocated StyleX owners; include the generated token CSS and extracted StyleX output.

For another project, bring the wrapper and its transitive local imports, configure the alias, install the dependencies those files use, and provide the same token and style setup. Review the source before copying; the direct imports below are not a complete dependency closure.

Interactive examples require client-side React. This TanStack Start site server-renders and hydrates React; another host must hydrate or render the component on the client. Example code is the same TypeScript source used by its live preview.

Direct imports in this wrapper:

  • ../../stylex/markers.stylex
  • ../../stylex/tokens.stylex
  • ./button-dimensions.stylex
  • ./input-group-contract
  • @radix-ui/react-slot
  • @stylexjs/stylex
  • react

Form validation and recovery

Submit an empty address, then correct it, optionally add a unit, and choose a priority. FormRow wraps paired fields to the available width; the error replaces street help. Controls become available after client hydration so the local preview cannot send a native form request before it is ready. Nothing is saved. Form exports layout pieces; NativeSelect takes native option elements and native change events.

Preview a job

Local example. Your entry stays in this page and is not saved.

This preview needs JavaScript. If the fields stay disabled, reload the page.

Use a sample address to try the form.

Suite, apartment, or unit if needed.

Example source
Read the complete example
import { useEffect, useId, useRef, useState, type FormEvent } from "react";
import { Button } from "@/components/ui/button";
import {
  FormActions,
  FormError,
  FormField,
  FormHelp,
  FormLabel,
  FormRow,
  FormSection,
  FormSectionDescription,
  FormSectionFields,
  FormSectionInfo,
  FormSectionTitle,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { NativeSelect } from "@/components/ui/native-select";

export function JobFormExample() {
  const id = useId();
  const street = useRef<HTMLInputElement>(null);
  const [ready, setReady] = useState(false);
  const [error, setError] = useState(false);
  const [preview, setPreview] = useState("");
  useEffect(() => setReady(true), []);

  function submit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    const address = String(data.get("street") ?? "").trim();
    const unit = String(data.get("unit") ?? "").trim();
    setError(!address);
    setPreview("");
    if (!address) {
      street.current?.focus();
      return;
    }
    setPreview(
      `Preview ready: ${address}${unit ? `, unit ${unit}` : ""}, ${data.get("priority")}. Nothing was saved.`,
    );
  }

  return (
    <form data-form-pilot="form" aria-labelledby={`${id}-title`} aria-busy={!ready} onSubmit={submit} noValidate>
      <FormSection>
        <FormSectionInfo>
          <FormSectionTitle id={`${id}-title`}>Preview a job</FormSectionTitle>
          <FormSectionDescription>
            Local example. Your entry stays in this page and is not saved.
          </FormSectionDescription>
          {!ready && (
            <FormHelp>
              This preview needs JavaScript. If the fields stay disabled, reload the page.
            </FormHelp>
          )}
        </FormSectionInfo>
        <FormSectionFields>
          <FormField>
            <FormLabel htmlFor={`${id}-street`}>Street address (required)</FormLabel>
            <Input
              ref={street}
              id={`${id}-street`}
              name="street"
              autoComplete="off"
              required
              disabled={!ready}
              aria-invalid={error || undefined}
              aria-describedby={`${id}-street-message`}
              onChange={() => {
                setError(false);
                setPreview("");
              }}
            />
            {error ? (
              <FormError id={`${id}-street-message`}>Enter a street address.</FormError>
            ) : (
              <FormHelp id={`${id}-street-message`}>Use a sample address to try the form.</FormHelp>
            )}
          </FormField>
          <FormRow>
            <FormField>
              <FormLabel htmlFor={`${id}-unit`}>Unit or suite (optional)</FormLabel>
              <Input
                id={`${id}-unit`}
                name="unit"
                autoComplete="off"
                disabled={!ready}
                aria-describedby={`${id}-unit-help`}
                onChange={() => setPreview("")}
              />
              <FormHelp id={`${id}-unit-help`}>Suite, apartment, or unit if needed.</FormHelp>
            </FormField>
            <FormField>
              <FormLabel htmlFor={`${id}-priority`}>Priority</FormLabel>
              <NativeSelect
                id={`${id}-priority`}
                name="priority"
                defaultValue="routine"
                disabled={!ready}
                onChange={() => setPreview("")}
              >
                <option value="routine">Routine</option>
                <option value="urgent">Urgent</option>
              </NativeSelect>
            </FormField>
          </FormRow>
          <FormActions>
            <Button type="submit" disabled={!ready}>
              Preview job
            </Button>
          </FormActions>
          <FormHelp role="status" aria-live="polite">
            {preview}
          </FormHelp>
        </FormSectionFields>
      </FormSection>
    </form>
  );
}

Adoption contract

  1. Start from the ShadCN accessible source primitive; keep upstream keyboard and focus behavior.
  2. Use Thorbis color, type, space, radius, shadow, and motion tokens only.
  3. Design hover, focus, selected, disabled, loading, empty, error, offline, and reduced-motion states where they apply.
  4. Verify desktop and 390px mobile, keyboard order, accessible names, and stable async geometry.
Official ShadCN component directory

Applied bookmark research

Reviewed 2026-09-06. These examples reinforce the existing owners; they do not introduce another visual style or claim the product behavior is implemented. Read the 26-source review and access limits.

A control keeps its meaning through every state

A button that changes width, a field that loses input, or a dialog that loses focus breaks the task even when the resting design looks precise.

Review the approved primitive as a sequence: resting, focused, activated, pending, resolved, and recovered. Keep the action label and footprint stable, associate errors with their fields, and preserve a visible keyboard path.

Thorbis example. Save estimate remains Save estimate while its state mark changes. A validation error keeps the entered values, explains the repair beside the field, and moves focus only when the interaction calls for it.

Verify. Test Tab, Shift-Tab, Enter, Escape where applicable, paste, invalid input, rapid activation, and a slow response. Repeat at 390px, in both themes, with zoom and reduced motion.

Do not import. No hand-built duplicate primitive, hidden label, disabled browser zoom, or borrowed shadow-only outer edge that violates D-017.

Open the ui skill · Evidence: Web Interface Guidelines; Invisible Details; components.build; Details that make interfaces feel better

Frequent work stays immediate; allowed motion can be interrupted

A small delay repeated through a working day becomes friction, and a transition that restarts from the wrong position disconnects feedback from input.

Apply the existing profile first: Workbench is press-only, Evidence has no motion, and other profiles remain constrained by their owner. Where a transition is already permitted, preserve continuity and the state footprint when input interrupts it.

Thorbis example. Command-menu selection follows the key immediately. A permitted popover opens from its trigger and survives open-close-open without snapping, losing focus, or changing the action label.

Verify. Repeat the interaction rapidly, switch between input methods, and test reduced motion. Compare with the nonanimated version at ordinary task speed. Use existing tokens rather than copying durations or curves from an article.

Do not import. No row stagger in routine work, delayed keyboard highlights, ornamental blur, minimum success delay, or automatic spring-library adoption.

Open the motion skill · Evidence: You Don't Need Animations; 7 Practical Animation Tips; Train Your Judgement; Details that make interfaces feel better

Loading search…

Loading search. You can close this message and keep reading.