Buttons

A clear next step. Theme-aware actions with native button semantics, expressive variants, and just enough API.

Btn

View source · API reference

Playground

Explore every variant and all ten semantic tones. Change the size, move the icon, or try the disabled state. The source tabs contain the complete playground: controls, reactive state, icon registration, and application mount.

<RegorApp id="button-playground" src="./button-playground.ts"/>
import {
  type BtnIconPosition,
  type BtnSize,
  type ComponentVariant,
  defineButtonComponents,
  defineFlexComponents,
  defineFormComponents,
  defineFormSelectField,
  defineGridComponents,
  defineIconComponents,
  type FormSelectOption,
} from '@purestack/ts-components'
import { SEMANTIC_TONES, type SemanticTone } from '@purestack/ts-style'
import {
  lucide_chevron_down,
  tabler_arrow_right,
  tabler_check,
  tabler_plus,
  tabler_refresh,
} from '@purestack/ts-svg-icons'
import {
  batch,
  type ComputedRef,
  computed,
  createApp,
  defineComponent,
  html,
  type Ref,
  ref,
} from 'regor'

export interface ButtonPlayground {
  tone: Ref<SemanticTone>
  variant: Ref<ComponentVariant>
  size: Ref<BtnSize>
  icon: Ref<string>
  position: Ref<BtnIconPosition>
  iconOnly: Ref<boolean>
  disabled: Ref<boolean>
  resolvedIcon: ComputedRef<string>
  resolvedIconOnly: ComputedRef<boolean>
  feedback: Ref<string>
  tones: FormSelectOption[]
  variants: FormSelectOption[]
  sizes: FormSelectOption[]
  icons: FormSelectOption[]
  positions: FormSelectOption[]
  activate: () => void
  reset: () => void
}

const variants: ComponentVariant[] = [
  'solid',
  'surface',
  'surfaceAlt',
  'spotlight',
  'glass',
  'flat',
  'flatAlt',
  'flatSolid',
  'outlineFill',
  'outline',
  'subtle',
  'subtleBtn',
  'link',
  'sheen',
  'underline',
  'rail',
  'bracket',
  'none',
]
const options = (values: string[]) =>
  values.map((value) => ({ label: value, value }))

const buttonPlaygroundTemplate = html`<Grid columns="1" columnsMd="2" alignItems="center">
  <Flex direction="column" align="center">
    <Btn
      :tone="tone"
      :variant="variant"
      :size="size"
      :icon="resolvedIcon"
      :iconPosition="position"
      :iconOnly="resolvedIconOnly"
      :disabled="disabled"
      ariaLabel="Create project"
      @click="activate"
    >
      Create project
    </Btn>
    <p role="status">{{ feedback }}</p>
  </Flex>
  <Grid columns="2">
    <FormSelectField
      id="button-tone"
      label="Tone"
      :model="tone"
      :options="tones"/>
    <FormSelectField
      id="button-variant"
      label="Variant"
      :model="variant"
      :options="variants"/>
    <FormSelectField
      id="button-size"
      label="Size"
      :model="size"
      :options="sizes"/>
    <FormSelectField
      id="button-icon"
      label="Icon"
      :model="icon"
      :options="icons"/>
    <FormSelectField
      id="button-position"
      label="Icon position"
      :model="position"
      :options="positions"/>
    <Flex direction="column" justify="center">
      <FormCheck id="button-disabled" label="Disabled" :checked="disabled"/>
      <FormCheck id="button-icon-only" label="Icon only" :checked="iconOnly"/>
    </Flex>
    <Btn variant="link" tone="neutral" icon="tabler:refresh" @click="reset">
      Reset
    </Btn>
  </Grid>
</Grid>`

function createButtonPlayground(): ButtonPlayground {
  const tone = ref<SemanticTone>('accent')
  const variant = ref<ComponentVariant>('solid')
  const size = ref<BtnSize>('md')
  const icon = ref('tabler:plus')
  const position = ref<BtnIconPosition>('start')
  const iconOnly = ref(false)
  const disabled = ref(false)
  const feedback = ref('Click the button to try it.')
  const count = ref(0)
  const resolvedIcon = computed(() => (icon() === 'none' ? '' : icon()))
  const resolvedIconOnly = computed(() => iconOnly() && resolvedIcon() !== '')
  const reset = () =>
    batch(() => {
      tone('accent')
      variant('solid')
      size('md')
      iconOnly(false)
      position('start')
      icon('tabler:plus')
      disabled(false)
      count(0)
      feedback('Click the button to try it.')
    })
  const activate = () => {
    count(count() + 1)
    feedback(`Action triggered ${count()} ${count() === 1 ? 'time' : 'times'}.`)
  }
  return {
    tone,
    variant,
    size,
    icon,
    position,
    iconOnly,
    resolvedIcon,
    resolvedIconOnly,
    disabled,
    feedback,
    activate,
    reset,
    tones: options(SEMANTIC_TONES),
    variants: options(variants),
    sizes: options(['sm', 'md', 'lg']),
    icons: options([
      'none',
      'tabler:plus',
      'tabler:check',
      'tabler:arrow-right',
    ]),
    positions: options(['start', 'end']),
  }
}

const icons: Record<string, string> = {
  'lucide:chevron-down': lucide_chevron_down,
  'tabler:arrow-right': tabler_arrow_right,
  'tabler:check': tabler_check,
  'tabler:plus': tabler_plus,
  'tabler:refresh': tabler_refresh,
}

const component = defineComponent<ButtonPlayground>(buttonPlaygroundTemplate, {
  context: createButtonPlayground,
})

createApp(
  {
    components: {
      ButtonPlayground: component,
      ...defineButtonComponents(),
      ...defineFlexComponents(),
      ...defineFormComponents(),
      ...defineFormSelectField(),
      ...defineGridComponents(),
      ...defineIconComponents((name) => {
        if (!icons[name]) throw new Error(`Icon is not registered: ${name}`)
        return icons[name]
      }),
    },
  },
  {
    selector: 'app#button-playground',
    template: html`<ButtonPlayground/>`,
  },
)

Usage

Use Btn to trigger an action, submit a form, or reset input. It renders a native button and is available directly in PureStack MDX. Choose its tone, variant, size, and icon to fit the task.

Static markup. Explicit interaction.

MDX renders components at build time. Use RegorApp to load a browser application when a button needs state or an event handler. The interaction example below includes the app mount and complete TypeScript source.

Variants

Choose the visual treatment independently from the tone. Filled variants suit primary actions; surface and outline variants give secondary actions structure. Link, underline, rail, and bracket variants work well in compact interfaces.

<Grid columns="2" columnsSm="3" alignItems="center" justifyItems="center" class="gap-4">
  <Btn tone="accent" variant="solid">solid</Btn>
  <Btn tone="accent" variant="surface">surface</Btn>
  <Btn tone="accent" variant="surfaceAlt">surfaceAlt</Btn>
  <Btn tone="accent" variant="spotlight">spotlight</Btn>
  <Btn tone="accent" variant="glass">glass</Btn>
  <Btn tone="accent" variant="flat">flat</Btn>
  <Btn tone="accent" variant="flatAlt">flatAlt</Btn>
  <Btn tone="accent" variant="flatSolid">flatSolid</Btn>
  <Btn tone="accent" variant="outlineFill">outlineFill</Btn>
  <Btn tone="accent" variant="outline">outline</Btn>
  <Btn tone="accent" variant="subtle">subtle</Btn>
  <Btn tone="accent" variant="subtleBtn">subtleBtn</Btn>
  <Btn tone="accent" variant="link">link</Btn>
  <Btn tone="accent" variant="sheen">sheen</Btn>
  <Btn tone="accent" variant="underline">underline</Btn>
  <Btn tone="accent" variant="rail">rail</Btn>
  <Btn tone="accent" variant="bracket">bracket</Btn>
  <Btn tone="accent" variant="none">none</Btn>
</Grid>

Semantic tones

Communicate intent through the palette. Every tone participates in the same theme system, including hover, active, and disabled states.

<Flex wrap="true"><Btn tone="neutral" variant="surface">neutral</Btn>
<Btn tone="accent" variant="surface">accent</Btn>
<Btn tone="feature" variant="surface">feature</Btn>
<Btn tone="secondary" variant="surface">secondary</Btn>
<Btn tone="custom" variant="surface">custom</Btn>
<Btn tone="ghost" variant="surface">ghost</Btn>
<Btn tone="info" variant="surface">info</Btn>
<Btn tone="success" variant="surface">success</Btn>
<Btn tone="warning" variant="surface">warning</Btn>
<Btn tone="danger" variant="surface">danger</Btn></Flex>

Sizes and icons

The sm, md, and lg sizes follow the active theme’s typography scale. Provide a registered icon name, then choose start or end. An icon-only button needs both an icon and an ariaLabel.

<Flex wrap="true" align="center">
  <Btn size="sm" tone="accent">Small</Btn>
  <Btn size="md" tone="accent" icon="tabler:plus">Create project</Btn>
  <Btn size="lg" tone="neutral">Large</Btn>
  <Btn tone="neutral" variant="surface" icon="tabler:arrow-right" iconPosition="end">Continue</Btn>
  <Btn tone="accent" variant="surface" icon="tabler:check" :iconOnly="true" ariaLabel="Confirm selection"/>
  <Btn tone="neutral" :disabled="true">Unavailable</Btn>
</Flex>

Browser interaction

Build a collection of up to five items. Add creates a visible list item; Remove deletes the last one; Reset clears the collection. The list stays intact when you switch tabs. Each source tab contains a complete file.

<RegorApp id="collection" src="./collection.ts"/>
import {
  defineButtonComponents,
  defineFlexComponents,
} from '@purestack/ts-components'
import {
  type ComputedRef,
  computed,
  createApp,
  defineComponent,
  html,
  type SRef,
  sref,
} from 'regor'

export interface Collection {
  items: SRef<string[]>
  limit: number
  summary: ComputedRef<string>
  isEmpty: ComputedRef<boolean>
  isFull: ComputedRef<boolean>
  addItem: () => void
  removeItem: () => void
  reset: () => void
}

const collectionTemplate = html`<Flex direction="column" align="start">
  <strong>Build a collection</strong>
  <p>Add up to {{ limit }} items. Remove one or start over.</p>
  <p role="status">{{ summary }}</p>
  <ul r-if="!isEmpty" aria-label="Collection items">
    <li r-for="item in items">{{ item }}</li>
  </ul>
  <Flex wrap="true">
    <Btn tone="accent" :disabled="isFull" @click="addItem">Add item</Btn>
    <Btn tone="neutral" variant="surface" :disabled="isEmpty" @click="removeItem">Remove item</Btn>
    <Btn tone="neutral" variant="link" :disabled="isEmpty" @click="reset">Reset</Btn>
  </Flex>
</Flex>`

function createCollection(): Collection {
  const items = sref<string[]>([])
  const limit = 5
  let nextItem = 1
  const isEmpty = computed(() => items().length === 0)
  const isFull = computed(() => items().length === limit)
  const summary = computed(() =>
    isFull()
      ? `Collection full: ${limit} items.`
      : `${items().length} ${items().length === 1 ? 'item' : 'items'} in your collection.`,
  )
  return {
    items,
    limit,
    summary,
    isEmpty,
    isFull,
    addItem: () => {
      if (!isFull()) items([...items(), `Item ${nextItem++}`])
    },
    removeItem: () => {
      if (!isEmpty()) items(items().slice(0, -1))
    },
    reset: () => {
      items([])
      nextItem = 1
    },
  }
}

const component = defineComponent<Collection>(collectionTemplate, {
  context: createCollection,
})

createApp(
  {
    components: {
      Collection: component,
      ...defineButtonComponents(),
      ...defineFlexComponents(),
    },
  },
  {
    selector: 'app#collection',
    template: html`<Collection/>`,
  },
)

Submit and reset

The default button type is button. Choose submit or reset explicitly inside a form. This sample validates the required field and handles submission locally.

The form handles @reset.prevent so its reactive value stays synchronized with the input instead of being overwritten by the browser’s native reset.

<RegorApp id="project-form" src="./project-form.ts"/>
import {
  defineButtonComponents,
  defineFlexComponents,
  defineFormInputField,
} from '@purestack/ts-components'
import { createApp, defineComponent, html, type Ref, ref } from 'regor'

export interface ProjectForm {
  project: Ref<string>
  message: Ref<string>
  submit: () => void
  resetForm: () => void
}

const projectFormTemplate = html`<Flex
  container="form"
  direction="column"
  @submit.prevent="submit"
  @reset.prevent="resetForm"
>
  <FormInputField
    id="demo-project-name"
    label="Project name"
    :model="project"
    name="project"
    :required="true"/>
  <Flex wrap="true">
    <Btn type="submit" tone="accent">Save project</Btn>
    <Btn type="reset" variant="surface" tone="neutral">Reset</Btn>
  </Flex>
  <p class="m-0" role="status">{{ message }}</p>
</Flex>`

function createProjectForm(): ProjectForm {
  const project = ref('My next idea')
  const message = ref('Changes stay in this demo.')
  return {
    project,
    message,
    submit: () => message(`Saved “${project()}”.`),
    resetForm: () => {
      project('My next idea')
      message('Form reset.')
    },
  }
}

const component = defineComponent<ProjectForm>(projectFormTemplate, {
  context: createProjectForm,
})

createApp(
  {
    components: {
      ProjectForm: component,
      ...defineButtonComponents(),
      ...defineFlexComponents(),
      ...defineFormInputField(),
    },
  },
  {
    selector: 'app#project-form',
    template: html`<ProjectForm/>`,
  },
)

Accessibility

  • Use a visible, specific label such as “Save project”.
  • Give icon-only controls an accessible name with ariaLabel.
  • A disabled button cannot be activated or reached with Tab. Use visible context to explain why an action is unavailable.
  • Keep navigation as links, including links styled as buttons.
  • Use bound booleans such as :disabled="false" when supplying boolean values through MDX.

API reference

<Btn />
9 props Native button

All props are optional. Pass a value for static markup or a RefOrValue<T> for reactive updates. Choose a property below to jump to its contract.

Open the playground · Read the component definition

Appearance

Color, treatment, and scale are independent. Combine them to fit the importance of the action.

tone

SemanticTone
Default
inherited

Sets the semantic color palette. Omit it to inherit the surrounding tone; the standard theme starts with neutral.

neutral accent feature secondary custom ghost info success warning danger
<Btn tone="danger">Delete project</Btn>

variant

ComponentVariant
Default
solid

Sets the button's visual weight. Start with solid for the primary action, outline for a secondary action, or link for a quieter one.

solidsurfacesurfaceAlt spotlightglassflatflatAltflatSolid outlineFilloutlinesubtle subtleBtnlinksheen underlinerailbracketnone

Compare all variants

<Btn tone="accent" variant="outline">View details</Btn>

size

BtnSize
Default
md

Uses the theme's typography scale. Omitting size keeps the same base styling as md.

<Btn size="sm">Add item</Btn>

Icons and labels

Keep the action understandable with or without its icon.

icon

string
Default
none

A registered icon in provider:name form. Omit it or pass an empty string to show only the label.

In browser apps, register icons with defineIconComponents. The playground source includes the imports and registration.

<Btn icon="tabler:plus">Create project</Btn>

iconPosition

BtnIconPosition
Default
start

Places the icon before or after the label. Requires an icon.

<Btn icon="tabler:arrow-right" iconPosition="end">Continue</Btn>

iconOnly

boolean
Default
false

Hides the label and uses compact icon padding. Accepts true or false.

Accessible name: Confirm selection

Supply both icon and ariaLabel. Hiding the label does not provide an accessible name.

<Btn
  icon="tabler:check"
  :iconOnly="true"
  ariaLabel="Confirm selection"/>

ariaLabel

string
Default
not set

Sets aria-label on the native button. When omitted, the visible label supplies its accessible name. Set it for icon-only controls or when a short visible label needs more context.

<Btn ariaLabel="Remove draft project">Remove</Btn>

Button behavior

Use native form behavior and boolean state to control what the action can do.

type

BtnType
Default
button

Chooses how the button interacts with its form. Unlike a bare HTML button, Btn defaults to button.

button
Runs a click handler without submitting the form.
submit
Validates and submits its associated form.
reset
Resets its associated form.

Try submit and reset with reactive form state

<Btn type="submit">Save project</Btn>

disabled

boolean
Default
false

Prevents activation and removes the button from keyboard tab order. Accepts true or false.

Bind booleans with :. The string "false" is not a boolean. Explain why an action is unavailable in nearby visible text.

<Btn :disabled="true">Unavailable</Btn>

Slots

defaultButton label

Text or inline markup placed between the tags becomes the visible label. It is hidden when iconOnly is true. Avoid nested links, buttons, or other interactive controls.

<Btn>Save changes</Btn>

Events

@click(event: MouseEvent) => void

Fires on pointer or keyboard activation when the button is enabled. The handler must live in a mounted Regor app; static MDX does not run browser handlers.

<Btn @click="save">Save changes</Btn>

See the complete event example. For forms, use type="submit" and handle @submit.prevent on the form.

Native attributes

Extra attributes pass through to the native <button>. Add id, class, name, value, form, title, data-*, or aria-* directly. Custom classes are added alongside theme classes.

<Btn
  type="submit"
  form="project-editor"
  name="intent"
  value="save"
  aria-describedby="save-help"
>
  Save project
</Btn>