Buttons
A clear next step. Theme-aware actions with native button semantics, expressive variants, and just enough API.
BtnPlayground
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.
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 /> 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.
<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 <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
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>