oks-ui

How to use

Button

Button is the pressable primitive nearly everything else in the library builds on — plain actions, form submits, links styled as buttons, and icon-only controls all go through the same component. It renders a native <button> by default and switches to an <a> automatically the moment you pass href, so you never have to choose the element yourself.

import { Button } from "oks-ui";

Variant and color

variant sets how much visual weight a button carries: solid (the default) is a filled, high-contrast action; soft is a tinted background for secondary actions that shouldn't compete with a solid primary button nearby; bordered keeps just an outline; ghost has no background or border until hovered; link strips button chrome entirely and reads as an inline text link. color is one of seven semantic roles and works the same way across every variant.

<Button variant="solid" color="primary">Save</Button>
<Button variant="soft" color="default">Cancel</Button>
<Button variant="bordered" color="danger">Delete</Button>
<Button variant="ghost" color="default">Skip</Button>
<Button variant="link" color="primary">Learn more</Button>

Rendering as a link

Pass href and Button renders a real anchor instead of a button element — no wrapping a Button inside a Link and fighting duplicate focus rings. target/rel/download all forward straight to the anchor, and rel defaults to "noopener noreferrer" automatically whenever target="_blank" is set, so external links can't be quietly forgotten. For a client-side router's own Link component, use the polymorphic as prop instead.

<Button href="/pricing" variant="soft">View pricing</Button>
<Button href="https://github.com/oks-ui" target="_blank">GitHub ↗</Button>

// Polymorphic: render as next/link (or any component) instead of <a>/<button>
<Button as={Link} href="/dashboard" color="primary">
  Go to dashboard
</Button>

Loading and disabled states

isLoading swaps in a spinner (spinnerPlacement controls which side of the label it sits on, or pass your own spinner node) and sets aria-busy — but the button stays focusable and enabled on purpose, so a screen reader user tabbed onto it doesn't lose their place mid-action. Use isDisabled separately when the action genuinely shouldn't be reachable at all; disabling removes it from the tab order and, on an anchor-rendered button, adds aria-disabled since native <a> has no disabled attribute.

<Button isLoading color="primary">Saving…</Button>
<Button isDisabled variant="soft">Unavailable</Button>

Icon-only buttons

For a button that's just an icon, set isIconOnly (which switches to a square footprint sized off the current size scale) and always pass an aria-label — there's no visible text for assistive tech to fall back on otherwise.

<Button isIconOnly variant="ghost" aria-label="Close">
  <XIcon />
</Button>

Press events, not just onClick

Button forwards native onClick as usual, but also exposes onPress/onPressStart/onPressEnd/onPressUp/onPressChange for cases that care about the full press lifecycle (press-and-hold interactions, custom ripple-adjacent effects, distinguishing a press that started on the button but was dragged off before release). Reach for plain onClick unless you specifically need one of those finer-grained moments.

Accessibility

  • Renders semantic <button type="button"> or <a> — never a styled <div> — so it's keyboard-operable and correctly announced without any extra ARIA wiring.
  • isLoading sets aria-busy="true" but deliberately leaves the button enabled and focusable, since disabling it mid-action would silently drop focus for a keyboard/screen-reader user.
  • isDisabled sets the native disabled attribute on a <button>, or aria-disabled + tabIndex={-1} when rendered as an anchor (href set), since <a> has no native disabled state.
  • isIconOnly buttons have no visible label — always pass your own aria-label so the control has an accessible name.

Props reference

All available props for Button. See the full component page for an interactive playground.

Props

children

ReactNode

Default:

Button label/content.

startContent

ReactNode

Default:

Content rendered before the label.

endContent

ReactNode

Default:

Content rendered after the label.

spinner

ReactNode

Default: built-in spinner

Overrides the default loading spinner.

spinnerPlacement

"start" | "end"

Default: start

Where the loading spinner renders relative to the label.

Best practices

  • Reserve variant="solid" for the one primary action in a given area — a screen with five solid buttons gives the eye nothing to anchor on.
  • Prefer isLoading over swapping the button out for a spinner entirely; keeping the button in place (just busy) avoids a layout shift and a lost focus target.
  • Use href (or as) instead of wrapping Button in a separate anchor/Link — the polymorphic behavior exists specifically so you never end up with two nested interactive elements.
  • Always pair isIconOnly with aria-label; if the icon also has a visible tooltip, the label should still describe the action on its own since tooltips aren't reliably read by every assistive technology.
  • For a set of related actions that should look like one connected control (Prev/Next, view toggles), use ButtonGroup instead of manually managing spacing and border-radius between adjacent Buttons.
View full API reference for Button