Photon 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:

  • effect — default; marketing pill with a same-hue 1.5px outer rim and an inset light/shadow sheen that springs up on hover and presses back in while held (always rounded; shape ignored)
  • filled — solid contrast fill without sheen
  • outlined — hairline ring, supporting action
  • ghost — borderless, quiet / inline action

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:

UseAPI
Dialog / alert confirmcolor="destructive" (default effect)
Danger-zone trigger in a listvariant="ghost" color="destructive"
Cancel / dismiss in a footervariant="ghost"
Supporting / OAuthvariant="outlined"
Solid fill without sheenvariant="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 sits 4px closer to its edge than the label does to the other (12/16, 14/18, 18/22px across sm/md/lg): an icon’s box brings its own whitespace, so equal padding would read as a looser icon side. 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

  • variant — effect · filled · outlined · ghost (default effect)
  • color — default · destructive (default default)
  • size — sm · md · lg (default md)
  • shape — rounded · square · circle (default rounded; ignored when variant="effect")
  • iconReveal — boolean (default false) — spring prefix/suffix icons in on hover (any variant)
  • prefix / suffix — a slot before / after the label (icon component or element)
  • svgOnly — render an icon-only button; requires aria-label
  • loading — show a spinner and block interaction
  • disabled — native disabled state
  • href (ButtonLink only) — navigate via LinkProvider

Accessibility

  • Semantics — Button renders a native <button type="button">; ButtonLink renders an <a> through LinkProvider.
  • Disabled — a disabled Button uses the native disabled attribute. A disabled ButtonLink sets aria-disabled and turns off pointer events.
  • Icon-only — svgOnly requires an aria-label naming the action; in development a missing label logs a console warning.
  • Loading — loading sets aria-busy and blocks pointer events while keeping the label in place.
  • Focus — focus shows a 2px outline on :focus-visible only; destructive surfaces use an error-toned outline.
  • Motion — press compression (every variant), the effect sheen spring, and the wake glint are decorative and carry no ARIA state; none run while disabled or loading, and the wake glint respects prefers-reduced-motion.

Keyboard:

  • Enter / Space — activate a Button
  • Enter — follow a ButtonLink

Best practices

  • Use Button for actions that mutate state; use ButtonLink for navigation.
  • Pick surface (variant) for emphasis, then color for intent. Dialog confirms are default effect + destructive; danger-zone list triggers are ghost + destructive — never both a header X and a footer confirm in the same dialog (actions owns dismiss).
  • Prefer bare <Button> over the deprecated EffectButton alias; use variant="filled" when you want solid without sheen.
  • Pass loading rather than swapping in a spinner.
  • Disable only when the action is impossible right now; pair with a tooltip that explains why.
  • Title-case the label and name what happens: Delete Project, not OK.
  • Icon-only buttons require both svgOnly and aria-label.