Pho Design System

Button

Trigger an action or event — submitting a form, opening a dialog, confirming a change. Button is for actions that mutate state; for navigation that changes the URL, use ButtonLink.

Visual style is two axes: variant is the surface (filled / outlined / ghost / effect), and color is the semantic ink (default / destructive).

Import

import { Button, ButtonLink } from "@photon-ai/pho-ui/components/button";

Sizes

Three sizes — sm, md, lg. The default is md.

<Button size="sm">Upload</Button>
<Button size="md">Upload</Button>
<Button size="lg">Upload</Button>

Variants

variant is the surface axis:

Every variant shares the same press compression (scale 0.97 while held). Only effect also springs its sheen.

<Button>Get started</Button>
<Button variant="filled">Upload</Button>
<Button variant="outlined">Upload</Button>
<Button variant="ghost">Upload</Button>

Colors

color is the semantic axis. Closed set for now: default and destructive.

Pair surface × color deliberately:

Use API
Dialog / alert confirm color="destructive" (default effect)
Danger-zone trigger in a list variant="ghost" color="destructive"
Cancel / dismiss in a footer variant="ghost"
Supporting / OAuth variant="outlined"
Solid fill without sheen variant="filled"
{/* default color — effect is the default surface */}
<Button>Get started</Button>
<Button variant="filled">Upload</Button>
<Button variant="outlined">Upload</Button>
<Button variant="ghost">Upload</Button>

{/* destructive */}
<Button color="destructive">Delete</Button>
<Button variant="filled" color="destructive">Delete</Button>
<Button variant="outlined" color="destructive">Delete</Button>
<Button variant="ghost" color="destructive">Delete</Button>

Shapes

shape is rounded (the default pill — every button on photon.codes), square for dense app chrome, or circle for round icon buttons. Icon-only buttons must set svgOnly and an aria-label that names the action.

square uses rounded-base (12px) at every size; lg also adds squircle smoothing — the same corner treatment as the lg Input, so 44px buttons and 44px fields share a corner language.

import { IconArrowUp } from "@tabler/icons-react";

{/* filled — effect ignores shape */}
<Button variant="filled" svgOnly aria-label="Upload" shape="square">
  <IconArrowUp />
</Button>
<Button variant="filled" svgOnly aria-label="Upload" shape="circle">
  <IconArrowUp />
</Button>

Prefix and suffix

prefix and suffix are generic slots placed before and after the label. Pass an icon component (IconPlus) or an element (<IconPlus />).

A side that carries an icon tucks its padding in by the golden ratio (padding ÷ 1.618 — 14→8px, 16→10px, 20→12px across sm/md/lg): an icon’s rounded form brings its own whitespace, so full text-edge padding would read as a hole. The spinner shown by loading counts as an icon too.

import { IconArrowLeft, IconArrowRight, IconPlus } from "@tabler/icons-react";

<Button prefix={IconArrowLeft}>Back</Button>
<Button suffix={IconArrowRight}>Continue</Button>
<Button variant="outlined" prefix={IconPlus} suffix={IconArrowRight}>
  New project
</Button>

Loading

Pass loading instead of swapping in your own spinner — the button stays readable, announces its busy state (aria-busy), and blocks interaction.

<Button loading>Saving</Button>

Disabled

<Button disabled>Upload</Button>

When an effect pill leaves disabled (or loading), it plays a one-shot wake glint: the inset sheen starts from its pressed-in position, lifts past the hover line, and settles at rest — the pill’s own hover/press gesture replayed once to say “ready”. Shadow-only (no movement), skipped under reduced motion.

<Button disabled={!formValid}>Confirm</Button>

Disabled variants

<Button disabled variant="filled">Filled</Button>
<Button disabled variant="outlined">Outlined</Button>
<Button disabled variant="ghost">Ghost</Button>
<Button disabled variant="filled" color="destructive">Destructive</Button>

Use ButtonLink for navigation. It shares every visual prop with Button and adds href, rendering the host router’s link through LinkProvider.

import { IconArrowRight } from "@tabler/icons-react";

<ButtonLink href="/docs" variant="outlined" suffix={IconArrowRight}>
  View docs
</ButtonLink>;

Icon reveal

iconReveal is a separate boolean (default false). When true, prefix / suffix icons are collapsed and invisible at rest, then spring in on hover — independent of variant, so it works on filled, outlined, ghost, and effect.

import { IconArrowRight } from "@tabler/icons-react";

<Button iconReveal suffix={IconArrowRight}>Continue</Button>
<Button variant="outlined" iconReveal suffix={IconArrowRight}>
  Continue
</Button>
<Button iconReveal size="lg" suffix={IconArrowRight}>
  Book a demo
</Button>

Props

Accessibility

Keyboard:

Best practices