Skip to content
  1. Keyline UI
  2. Button
Keyline UIComponents
  • Get started
    • Installation
    • Theming
    • Modes
    • Configuration
    • Composition
  • Actions
    • Button
    • Button group
    • Split button
    • Toggle
    • Toggle group
    • Toolbar
    • Menu
    • Menubar
    • Context menu
  • Inputs
    • Field and form
    • Checkbox
    • Checkbox group
    • Radio group
    • Switch
    • Slider
    • Segmented
    • Select
    • Native select
    • Combobox
    • Multiselect
    • Number field
    • OTP field
    • Time field
    • Date picker and calendar
    • Date range
  • Data
    • Table
    • Chart
    • Stat and meter
    • Status and badge
    • Progress
    • Skeleton
    • Empty
    • Avatar
    • Kbd
    • Marker
  • Navigation
    • Tabs
    • Breadcrumb
    • Pagination
    • Nav
    • Navigation menu
    • Command menu
  • Overlays
    • Dialog
    • Alert dialog
    • Drawer
    • Sheet
    • Popover
    • Hover card
    • Tooltip
    • Toast
    • Notice
  • Layout
    • App shell
    • Sidebar
    • Panel
    • Resizable
    • Scroll area
    • Accordion
    • Collapsible
    • Carousel
    • Inspector
    • Item
    • Direction
  • Conversation
    • Message
    • Bubble
    • Message scroller
    • Attachment
    • Questionnaire
  • Blocks
    • Shipments

Button

A command in its own frame. While it works, the button says so: pending, done and failed happen inside the frame, so nothing around it moves.

Loading the live example…

"use client"

import { useEffect, useRef, useState } from "react"
import { Button } from "@/components/keyline/ui/button"

type Signal = "idle" | "pending" | "done"

export function InvoiceActions() {
  const [signal, setSignal] = useState<Signal>("idle")
  const [sentAt, setSentAt] = useState<string | null>(null)
  const [paid, setPaid] = useState(false)
  const timer = useRef<ReturnType<typeof setTimeout>>(undefined)
  useEffect(() => () => clearTimeout(timer.current), [])

  const email = () => {
    setSignal("pending")
    // stands in for the request to your mail service
    timer.current = setTimeout(() => {
      setSentAt(new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }))
      setSignal("done")
      timer.current = setTimeout(() => setSignal("idle"), 1600)
    }, 1200)
  }

  return (
    <div className="grid gap-4">
      <dl className="m-0 grid grid-cols-[auto_1fr] gap-x-6 gap-y-1 text-[14px]">
        <dt className="text-kl-ink-3">Invoice</dt>
        <dd className="m-0">
          <span className="font-kl-mono whitespace-nowrap">INV-2207</span> for order{" "}
          <span className="font-kl-mono whitespace-nowrap">DM-4102</span>
        </dd>
        <dt className="text-kl-ink-3">Client</dt>
        <dd className="m-0">Scranton Business Park</dd>
        <dt className="text-kl-ink-3">Amount</dt>
        <dd className="m-0 tabular-nums">$1,284.00 · {paid ? "paid" : "due 12 Nov"}</dd>
      </dl>
      <div className="flex flex-wrap gap-3">
        <Button variant="primary" signal={signal} onClick={email}>
          {sentAt ? "Email again" : "Email invoice"}
        </Button>
        <Button disabled={paid} onClick={() => setPaid(true)}>
          {paid ? "Marked as paid" : "Mark as paid"}
        </Button>
      </div>
      <p role="status" className="m-0 min-h-5 text-[14px] text-kl-ink-2">
        {signal === "pending"
          ? "Sending to [email protected]…"
          : sentAt
            ? `Emailed at ${sentAt} to [email protected], copy to Angela Martin.`
            : "Not sent yet."}
      </p>
    </div>
  )
}

Installation

npx shadcn@latest add @keyline-ui/button

Run it where your components.json is. The first time, map the @keyline-ui registry: one command, in Installation.

Usage

import { Button } from "@/components/keyline/ui/button"
<Button variant="primary" signal={sending ? "pending" : "idle"} onClick={send}>
  Email invoice
</Button>

Examples

Variants

One primary per decision. destructive is for the action that loses something, and quiet for the one you can always come back to.

Loading the live example…

"use client"

import { useState } from "react"
import { Button } from "@/components/keyline/ui/button"

type Decision = "approved" | "receipt" | "skipped" | "rejected" | null

export function ExpenseReview() {
  const [decision, setDecision] = useState<Decision>(null)
  const result = {
    approved: "Approved. Accounting pays it with Friday's run.",
    receipt: "Kevin has been asked for the receipt.",
    skipped: "Skipped. It comes back to the top of tomorrow’s queue.",
    rejected: "Rejected. Kevin gets a note from Angela.",
  }

  return (
    <div className="grid gap-4">
      <p className="m-0 text-[14px]">
        Kevin Malone · <span className="tabular-nums">$14.99</span> · “Cookies for the 3 o’clock meeting”
      </p>
      <div className="flex flex-wrap gap-3">
        <Button variant="primary" onClick={() => setDecision("approved")}>
          Approve
        </Button>
        <Button onClick={() => setDecision("receipt")}>Ask for receipt</Button>
        <Button variant="quiet" onClick={() => setDecision("skipped")}>
          Skip
        </Button>
        <Button variant="destructive" onClick={() => setDecision("rejected")}>
          Reject
        </Button>
      </div>
      <p role="status" className="m-0 min-h-5 text-[14px] text-kl-ink-2">
        {decision ? result[decision] : "Waiting for a decision."}
      </p>
    </div>
  )
}

Failed and retry

signal="failed" puts a ! cell at the start of the frame. Say what failed beside it and keep the button in place, so the retry is where the reader is already looking.

Loading the live example…

"use client"

import { useEffect, useRef, useState } from "react"
import { Button } from "@/components/keyline/ui/button"

type Signal = "idle" | "pending" | "done" | "failed"

export function CorporateSync() {
  const [signal, setSignal] = useState<Signal>("idle")
  const attempts = useRef(0)
  const timer = useRef<ReturnType<typeof setTimeout>>(undefined)
  useEffect(() => () => clearTimeout(timer.current), [])

  const sync = () => {
    setSignal("pending")
    attempts.current += 1
    // for the demo, the first attempt fails and the second goes through
    const fails = attempts.current === 1
    timer.current = setTimeout(() => setSignal(fails ? "failed" : "done"), 1000)
  }

  return (
    <div className="grid gap-4">
      <Button signal={signal} onClick={sync} className="justify-self-start">
        {signal === "failed" ? "Try again" : "Send 3 orders to corporate"}
      </Button>
      <p role="status" className="m-0 min-h-10 max-w-[48ch] text-[14px] text-kl-ink-2">
        {signal === "pending" && "Sending to the New York office…"}
        {signal === "failed" && "Corporate didn’t answer. The 3 orders are still saved here; try again."}
        {signal === "done" && "Sent. Corporate has orders DM-4102, DM-4087 and DM-4120."}
        {signal === "idle" && "3 orders are waiting for the end-of-day sync."}
      </p>
    </div>
  )
}

Affix

ButtonAffix adds a cell to the frame for a count, a unit or a shortcut. Its keyline separates it from the label.

Loading the live example…

"use client"

import { useState } from "react"
import { Button, ButtonAffix } from "@/components/keyline/ui/button"

export function WarehouseQueue() {
  const [slips, setSlips] = useState(4)
  const [printed, setPrinted] = useState(0)

  const print = () => {
    setPrinted((p) => p + slips)
    setSlips(0)
  }

  return (
    <div className="grid gap-4">
      <div className="flex flex-wrap gap-3">
        <Button disabled={slips === 0} onClick={print}>
          Print packing slips
          <ButtonAffix>{slips}</ButtonAffix>
        </Button>
        <Button size="compact" onClick={() => setSlips((s) => s + 1)}>
          New order in
        </Button>
      </div>
      <p role="status" className="m-0 min-h-5 text-[14px] text-kl-ink-2">
        {slips === 0 ? `All caught up. ${printed} slips sent to Darryl’s printer.` : `${slips} slips waiting in the warehouse queue.`}
      </p>
    </div>
  )
}

Props

PropTypeDefaultDescription
variant"secondary" | "primary" | "destructive" | "quiet"Default "secondary"Primary is the one commitment on a screen. Quiet drops the frame and underlines its label.
size"default" | "compact" | "icon"Default "default"Icon is square; give it an aria-label.
signal"idle" | "pending" | "done" | "failed"Default "idle"pending runs a segment along the bottom edge and keeps focus while the button is busy. done inserts a ✓ cell, failed a ! cell. Return it to idle yourself.
layout"control" | "cell"Default "control"cell drops the frame, to sit as a cell of a ButtonGroup, a Panel footer or a table row.
renderReactElementDefault —Renders another element, such as a link: pass nativeButton={false} with it.

Also exported: ButtonAffix, and buttonVariants for styling a link or a trigger as a button. Built on Base UI Button; its other props pass through.

Sheet 7 of 73PreviousCompositionNextButton group
On this page