Button
A 48px button with a label and, if it helps, an icon in front. In four levels of emphasis.
Hover, press or tab to one to see the other 12 states.
import { Button } from 'rost/components/button/Button'
<Button>Get in touch</Button>
<Button variant="secondary" href="/work">See the work</Button>
<Button variant="tertiary">Cancel</Button>
<Button variant="secondary" href="/" icon={<ArrowLeft />}>Back</Button>src/components/button/Button.tsx
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, HTMLAttributes, ReactNode } from 'react'
import './button.css'
export type ButtonVariant = 'primary' | 'secondary' | 'tertiary' | 'borderless'
interface Common {
/** The look: Figma's `type` axis. */
readonly variant?: ButtonVariant
/** Optional, in front of the label: a 20px icon. Figma's `leading` slot, off by default. */
readonly icon?: ReactNode
readonly children?: ReactNode
readonly className?: string
/** `span` draws it without the button or link: for a preview inside something that's already one, or the current page in a nav (with `aria-current="page"`). */
readonly as?: 'span'
}
export type ButtonProps =
| (Common & Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'type'> & { readonly href?: undefined; readonly type?: 'button' | 'submit' | 'reset' })
| (Common & AnchorHTMLAttributes<HTMLAnchorElement> & { readonly href: string; readonly disabled?: boolean })
const b = 'rost-button'
/**
* A 48px button with a label and an optional icon in front. A `<button>`, or an
* `<a>` when it has an `href`. Hover, press and focus are the browser's.
* Built from contracts/button.contract.yaml.
*/
export function Button(props: ButtonProps) {
const { variant = 'primary', icon, children, className, as, ...rest } = props
const cls = [b, `${b}--type-${variant}`, className].filter(Boolean).join(' ')
const inner = (
<>
<span className={`${b}__background`} />
{icon && <span className={`${b}__icons-arrow-left`} aria-hidden="true">{icon}</span>}
<span className={`${b}__label`}>{children}</span>
<span className={`${b}__focus-ring`} />
</>
)
if (as === 'span') {
const { disabled, type: _type, ...span } = rest as ButtonHTMLAttributes<HTMLButtonElement>
return <span className={cls} aria-disabled={disabled || undefined} {...(span as HTMLAttributes<HTMLSpanElement>)}>{inner}</span>
}
if ('href' in rest && rest.href !== undefined) {
const { href, disabled, ...a } = rest as AnchorHTMLAttributes<HTMLAnchorElement> & { disabled?: boolean }
return <a className={cls} href={disabled ? undefined : href} aria-disabled={disabled || undefined} {...a}>{inner}</a>
}
const { type = 'button', ...btn } = rest as ButtonHTMLAttributes<HTMLButtonElement>
return <button type={type} className={cls} {...btn}>{inner}</button>
}When to use
- Doing something: save, send, continue, open.
Primaryfor the one thing a screen is really about. One per screen, no more.Secondaryfor the options next to it,Tertiarywhen little is at stake,Borderlessin busy places where an outline would just be noise.- An icon when it helps people get it faster: a back arrow, an external link, a download.
When not to use
- When the icon is just decoration. Leave it out.
- When there's no room for the label. That's IconButton, never a Button with its label hidden.
- Picking between options. That's Toggle or Chip.
Behaviour
type sets how loud it is. state is never something you pass in code: hover, pressed and focus come from :hover, :active and :focus-visible. Disabled is the only one you set yourself. The background fades to opacity/gentle and the label stays readable. In a nav, the page you're on wears the pressed look (aria-current="page"): it reads as chosen, not as broken. The icon sits in a 20px slot (icons-arrow-left in Figma) and takes the label's colour.
Accessibility
- Element:
<button type="button">,type="submit"inside a form, or<a>when it takes you somewhere. A button that navigates is still a link, so it keeps open-in-new-tab and the back button. - Name: the label people see. Don't swap it for an
aria-labelthat says something else. The icon isaria-hidden="true". - Keyboard: Enter, and Space for buttons.
- Focus: the
focus-ringpart, 2px, only on:focus-visible. - Disabled: I'd rather use
aria-disabled="true"with a short reason nearby thandisabled.disableddrops the button out of the tab order and nobody can find out why it doesn't work.
Content
A verb, sentence case, two or three words. "Save changes", not "OK" or "SUBMIT". Say what will happen. An icon has to match the words: an arrow pointing left only on "Back".
Props
| Prop | Type | Values | Default |
|---|---|---|---|
| icon | slot | ||
| type | enum | Borderless · Primary · Secondary · Tertiary | Borderless |
States: Default, Hover, Pressed, Focus, Disabled.
Where it's used
- runceanu.com
src/rost/button/Button.tsx.rost-button - akount.ing
src/rost/button/Button.tsx.rost-button
Where code differs on purpose
- Everywhere:Figma has Button twice: the set at 20:14 (PillButton, with a `leading` icon slot that's off by default) and the one at 39:38 (Button, label only). Rost has one Button with an optional icon, built from 20:14.They have the same height, types, states and tokens. The other differences were layer names (`text` against `label`) and the Primary stroke, which is an inset shadow here because it takes no space. 20:14's screenshots all match; 39:38's came back empty. Merged on 2026-10-23.
Values
Straight from the contract. Each value sits next to its token, because six tokens here all come out as #3a3a3a and the colour alone can't tell you which one is right.
Never changes
| Part | Property | Value | Token |
|---|---|---|---|
| root | justify-content | center | layout |
| root | align-items | center | layout |
| root | display | inline-flex | layout |
| root | vertical-align | middle | layout |
| root | position | relative | layout |
| root | background | none | Figma frame size |
| root | border | none | Figma frame size |
| root | font | inherit | Figma frame size |
| root | text-decoration | none | Figma frame size |
| root | cursor | pointer | Figma frame size |
| root | outline | none | Figma frame size |
| root | gap | 8px | spacing/gap/small |
| root | height | 48px | spacing/size/medium-large |
| root | border-radius | 10000px | spacing/radius/full |
| label | flex-shrink | 0 | layout |
| label | white-space | nowrap | layout |
| label | text-align | center | layout |
| label | position | relative | layout |
| label | font-family | 'General Sans Variable' | font-family/base |
| label | font-weight | 500 | typography/body/font-weight/medium |
| label | line-height | 22px | typography/body/line-height |
| label | font-size | 16px | typography/body/font-size |
| label | letter-spacing | 0px | typography/body/letter-spacing |
| icons-arrow-left | display | flex | layout |
| icons-arrow-left | align-items | center | layout |
| icons-arrow-left | justify-content | center | layout |
| icons-arrow-left | overflow | clip | layout |
| icons-arrow-left | flex-shrink | 0 | layout |
| icons-arrow-left | position | relative | layout |
| icons-arrow-left | width | 20px | spacing/size/xsmall |
| icons-arrow-left | height | 20px | spacing/size/xsmall |
| union | position | absolute | layout |
| union | inset | 20.84% 16.66% 20.83% 18.4% | layout |
| focus-ring | display | none | layout |
| focus-ring | border-style | solid | no token yet |
| focus-ring | pointer-events | none | Figma frame size |
| background | position | absolute | layout |
| background | inset | 0 | layout |
| background | pointer-events | none | Figma frame size |
Changes by variant
background.background9
| type=Tertiary, state=Hover | #060805 | actions/tertiary/hover/background |
| type=Tertiary, state=Pressed | #454545 | actions/tertiary/pressed/background |
| type=Borderless, state=Hover | #060805 | actions/tertiary/hover/background |
| type=Borderless, state=Pressed | #454545 | actions/tertiary/pressed/background |
| type=Secondary, state=Default | #3a3a3a | actions/secondary/regular/default/background |
| type=Secondary, state=Hover | #747573 | actions/secondary/regular/hover/background |
| type=Secondary, state=Pressed | #999 | actions/secondary/regular/pressed/background |
| type=Secondary, state=Disabled | #3a3a3a | actions/secondary/regular/disabled/background |
| type=Secondary, state=Focus | #3a3a3a | actions/secondary/regular/focus/background |
background.border-color5
| type=Secondary, state=Default | #ededed | actions/secondary/regular/default/outline |
| type=Secondary, state=Hover | #ededed | actions/secondary/regular/hover/outline |
| type=Secondary, state=Pressed | #ededed | actions/secondary/regular/pressed/outline |
| type=Secondary, state=Disabled | #ededed | actions/secondary/regular/disabled/outline |
| type=Secondary, state=Focus | #ededed | actions/secondary/regular/focus/outline |
background.border-radius7
| type=Tertiary, state=Hover | 10000px | spacing/radius/full |
| type=Tertiary, state=Pressed | 10000px | spacing/radius/full |
| type=Secondary, state=Default | 10000px | spacing/radius/full |
| type=Secondary, state=Hover | 10000px | spacing/radius/full |
| type=Secondary, state=Pressed | 10000px | spacing/radius/full |
| type=Secondary, state=Disabled | 10000px | spacing/radius/full |
| type=Secondary, state=Focus | 10000px | spacing/radius/full |
background.border-sides5
| type=Secondary, state=Default | left top | layout |
| type=Secondary, state=Hover | left top | layout |
| type=Secondary, state=Pressed | left top | layout |
| type=Secondary, state=Disabled | left top | layout |
| type=Secondary, state=Focus | left top | layout |
background.inset9
| type=Tertiary, state=Hover | 0 | layout |
| type=Tertiary, state=Pressed | 0 | layout |
| type=Borderless, state=Hover | 0 | layout |
| type=Borderless, state=Pressed | 0 | layout |
| type=Secondary, state=Default | 0 | layout |
| type=Secondary, state=Hover | 0 | layout |
| type=Secondary, state=Pressed | 0 | layout |
| type=Secondary, state=Disabled | 0 | layout |
| type=Secondary, state=Focus | 0 | layout |
background.opacity9
| type=Tertiary, state=Hover | 0.32 | opacity/mild |
| type=Tertiary, state=Pressed | 0.32 | opacity/mild |
| type=Borderless, state=Hover | 0.32 | opacity/mild |
| type=Borderless, state=Pressed | 0.32 | opacity/mild |
| type=Secondary, state=Default | 0.24 | opacity/gentle |
| type=Secondary, state=Hover | 0.32 | opacity/mild |
| type=Secondary, state=Pressed | 0.32 | opacity/mild |
| type=Secondary, state=Disabled | 0.32 | opacity/mild |
| type=Secondary, state=Focus | 0.24 | opacity/gentle |
background.position9
| type=Tertiary, state=Hover | absolute | layout |
| type=Tertiary, state=Pressed | absolute | layout |
| type=Borderless, state=Hover | absolute | layout |
| type=Borderless, state=Pressed | absolute | layout |
| type=Secondary, state=Default | absolute | layout |
| type=Secondary, state=Hover | absolute | layout |
| type=Secondary, state=Pressed | absolute | layout |
| type=Secondary, state=Disabled | absolute | layout |
| type=Secondary, state=Focus | absolute | layout |
focus-ring.border-color4
| type=Primary, state=Focus | #b6ffda | actions/primary/focus/focus-ring |
| type=Tertiary, state=Focus | #999 | actions/secondary/regular/focus/focus-ring |
| type=Borderless, state=Focus | #999 | actions/secondary/regular/focus/focus-ring |
| type=Secondary, state=Focus | #999 | actions/secondary/regular/focus/focus-ring |
focus-ring.border-radius3
| type=Primary, state=Focus | 10000px | spacing/radius/full |
| type=Tertiary, state=Focus | 10000px | spacing/radius/full |
| type=Secondary, state=Focus | 10000px | spacing/radius/full |
focus-ring.border-sides4
| type=Primary, state=Focus | all | layout |
| type=Tertiary, state=Focus | all | layout |
| type=Borderless, state=Focus | all | layout |
| type=Secondary, state=Focus | all | layout |
focus-ring.border-width4
| type=Primary, state=Focus | 2px | spacing/outline/medium |
| type=Tertiary, state=Focus | 2px | spacing/outline/medium |
| type=Borderless, state=Focus | 2px | spacing/outline/medium |
| type=Secondary, state=Focus | 2px | spacing/outline/medium |
focus-ring.display4
| type=Primary, state=Focus | block | layout |
| type=Tertiary, state=Focus | block | layout |
| type=Borderless, state=Focus | block | layout |
| type=Secondary, state=Focus | block | layout |
focus-ring.inset4
| type=Primary, state=Focus | calc(-1 * var(--spacing-gap-2xsmall)) | layout |
| type=Tertiary, state=Focus | calc(-1 * var(--spacing-gap-2xsmall)) | layout |
| type=Borderless, state=Focus | calc(-1 * var(--spacing-gap-2xsmall)) | layout |
| type=Secondary, state=Focus | calc(-1 * var(--spacing-gap-2xsmall)) | layout |
focus-ring.position4
| type=Primary, state=Focus | absolute | layout |
| type=Tertiary, state=Focus | absolute | layout |
| type=Borderless, state=Focus | absolute | layout |
| type=Secondary, state=Focus | absolute | layout |
label.color20
| type=Primary, state=Default | #060805 | actions/primary/default/label |
| type=Primary, state=Hover | #060805 | actions/primary/hover/label |
| type=Primary, state=Focus | #060805 | actions/primary/focus/label |
| type=Primary, state=Pressed | #060805 | actions/primary/pressed/label |
| type=Primary, state=Disabled | #060805 | actions/primary/disabled/label |
| type=Tertiary, state=Default | #d3d9d5 | actions/tertiary/default/label |
| type=Tertiary, state=Hover | #ededed | actions/tertiary/hover/label |
| type=Tertiary, state=Pressed | #d3d9d5 | actions/tertiary/pressed/label |
| type=Tertiary, state=Disabled | #d3d9d5 | actions/tertiary/disabled/label |
| type=Tertiary, state=Focus | #d3d9d5 | actions/tertiary/focus/label |
| type=Borderless, state=Default | #d3d9d5 | actions/tertiary/default/label |
| type=Borderless, state=Hover | #ededed | actions/tertiary/hover/label |
| type=Borderless, state=Pressed | #d3d9d5 | actions/tertiary/pressed/label |
| type=Borderless, state=Disabled | #d3d9d5 | actions/tertiary/disabled/label |
| type=Borderless, state=Focus | #d3d9d5 | actions/tertiary/focus/label |
| type=Secondary, state=Default | #d3d9d5 | actions/secondary/regular/default/label |
| type=Secondary, state=Hover | #ededed | actions/secondary/regular/hover/label |
| type=Secondary, state=Pressed | #d3d9d5 | actions/secondary/regular/pressed/label |
| type=Secondary, state=Disabled | #d3d9d5 | actions/secondary/regular/disabled/label |
| type=Secondary, state=Focus | #d3d9d5 | actions/secondary/regular/focus/label |
root.background5
| type=Primary, state=Default | #00d669 | actions/primary/default/background |
| type=Primary, state=Hover | #00b450 | actions/primary/hover/background |
| type=Primary, state=Focus | #00d669 | actions/primary/focus/background |
| type=Primary, state=Pressed | #00913a | actions/primary/pressed/background |
| type=Primary, state=Disabled | #00d669 | actions/primary/disabled/background |
root.box-shadow5
| type=Primary, state=Default | inset var(--spacing-outline-small) var(--spacing-outline-small) 0 0 var(--actions-primary-default-outline) | on purpose: Figma's 1px left and top stroke, drawn inside the shape the way Figma draws it. A border would make the button 1px wider. The width and colour are tokens. |
| type=Primary, state=Hover | inset var(--spacing-outline-small) var(--spacing-outline-small) 0 0 var(--actions-primary-hover-outline) | on purpose: Figma's 1px left and top stroke, drawn inside the shape the way Figma draws it. A border would make the button 1px wider. The width and colour are tokens. |
| type=Primary, state=Focus | inset var(--spacing-outline-small) var(--spacing-outline-small) 0 0 var(--actions-primary-focus-outline) | on purpose: Figma's 1px left and top stroke, drawn inside the shape the way Figma draws it. A border would make the button 1px wider. The width and colour are tokens. |
| type=Primary, state=Pressed | inset var(--spacing-outline-small) var(--spacing-outline-small) 0 0 var(--actions-primary-pressed-outline) | on purpose: Figma's 1px left and top stroke, drawn inside the shape the way Figma draws it. A border would make the button 1px wider. The width and colour are tokens. |
| type=Primary, state=Disabled | inset var(--spacing-outline-small) var(--spacing-outline-small) 0 0 var(--actions-primary-disabled-outline) | on purpose: Figma's 1px left and top stroke, drawn inside the shape the way Figma draws it. A border would make the button 1px wider. The width and colour are tokens. |
root.cursor4
| type=Primary, state=Disabled | default | Figma frame size |
| type=Tertiary, state=Disabled | default | Figma frame size |
| type=Borderless, state=Disabled | default | Figma frame size |
| type=Secondary, state=Disabled | default | Figma frame size |
root.opacity4
| type=Primary, state=Disabled | 0.24 | opacity/gentle |
| type=Tertiary, state=Disabled | 0.24 | opacity/gentle |
| type=Borderless, state=Disabled | 0.24 | opacity/gentle |
| type=Secondary, state=Disabled | 0.24 | opacity/gentle |
root.padding5
| type=Borderless, state=Default | 0px | spacing/gap/none |
| type=Borderless, state=Hover | 0px | spacing/gap/none |
| type=Borderless, state=Pressed | 0px | spacing/gap/none |
| type=Borderless, state=Disabled | 0px | spacing/gap/none |
| type=Borderless, state=Focus | 0px | spacing/gap/none |
root.padding-bottom15
| type=Primary, state=Default | 0px | spacing/gap/none |
| type=Primary, state=Hover | 0px | spacing/gap/none |
| type=Primary, state=Focus | 0px | spacing/gap/none |
| type=Primary, state=Pressed | 0px | spacing/gap/none |
| type=Primary, state=Disabled | 0px | spacing/gap/none |
| type=Tertiary, state=Default | 0px | spacing/gap/none |
| type=Tertiary, state=Hover | 0px | spacing/gap/none |
| type=Tertiary, state=Pressed | 0px | spacing/gap/none |
| type=Tertiary, state=Disabled | 0px | spacing/gap/none |
| type=Tertiary, state=Focus | 0px | spacing/gap/none |
| type=Secondary, state=Default | 0px | spacing/gap/none |
| type=Secondary, state=Hover | 0px | spacing/gap/none |
| type=Secondary, state=Pressed | 0px | spacing/gap/none |
| type=Secondary, state=Disabled | 0px | spacing/gap/none |
| type=Secondary, state=Focus | 0px | spacing/gap/none |
root.padding-left15
| type=Primary, state=Default | 20px | spacing/gap/medium-large |
| type=Primary, state=Hover | 20px | spacing/gap/medium-large |
| type=Primary, state=Focus | 20px | spacing/gap/medium-large |
| type=Primary, state=Pressed | 20px | spacing/gap/medium-large |
| type=Primary, state=Disabled | 20px | spacing/gap/medium-large |
| type=Tertiary, state=Default | 20px | spacing/gap/medium-large |
| type=Tertiary, state=Hover | 20px | spacing/gap/medium-large |
| type=Tertiary, state=Pressed | 20px | spacing/gap/medium-large |
| type=Tertiary, state=Disabled | 20px | spacing/gap/medium-large |
| type=Tertiary, state=Focus | 20px | spacing/gap/medium-large |
| type=Secondary, state=Default | 20px | spacing/gap/medium-large |
| type=Secondary, state=Hover | 20px | spacing/gap/medium-large |
| type=Secondary, state=Pressed | 20px | spacing/gap/medium-large |
| type=Secondary, state=Disabled | 20px | spacing/gap/medium-large |
| type=Secondary, state=Focus | 20px | spacing/gap/medium-large |
root.padding-right15
| type=Primary, state=Default | 20px | spacing/gap/medium-large |
| type=Primary, state=Hover | 20px | spacing/gap/medium-large |
| type=Primary, state=Focus | 20px | spacing/gap/medium-large |
| type=Primary, state=Pressed | 20px | spacing/gap/medium-large |
| type=Primary, state=Disabled | 20px | spacing/gap/medium-large |
| type=Tertiary, state=Default | 20px | spacing/gap/medium-large |
| type=Tertiary, state=Hover | 20px | spacing/gap/medium-large |
| type=Tertiary, state=Pressed | 20px | spacing/gap/medium-large |
| type=Tertiary, state=Disabled | 20px | spacing/gap/medium-large |
| type=Tertiary, state=Focus | 20px | spacing/gap/medium-large |
| type=Secondary, state=Default | 20px | spacing/gap/medium-large |
| type=Secondary, state=Hover | 20px | spacing/gap/medium-large |
| type=Secondary, state=Pressed | 20px | spacing/gap/medium-large |
| type=Secondary, state=Disabled | 20px | spacing/gap/medium-large |
| type=Secondary, state=Focus | 20px | spacing/gap/medium-large |
root.padding-top15
| type=Primary, state=Default | 0px | spacing/gap/none |
| type=Primary, state=Hover | 0px | spacing/gap/none |
| type=Primary, state=Focus | 0px | spacing/gap/none |
| type=Primary, state=Pressed | 0px | spacing/gap/none |
| type=Primary, state=Disabled | 0px | spacing/gap/none |
| type=Tertiary, state=Default | 0px | spacing/gap/none |
| type=Tertiary, state=Hover | 0px | spacing/gap/none |
| type=Tertiary, state=Pressed | 0px | spacing/gap/none |
| type=Tertiary, state=Disabled | 0px | spacing/gap/none |
| type=Tertiary, state=Focus | 0px | spacing/gap/none |
| type=Secondary, state=Default | 0px | spacing/gap/none |
| type=Secondary, state=Hover | 0px | spacing/gap/none |
| type=Secondary, state=Pressed | 0px | spacing/gap/none |
| type=Secondary, state=Disabled | 0px | spacing/gap/none |
| type=Secondary, state=Focus | 0px | spacing/gap/none |