IconButton

A button whose only content is an icon. label is a required prop and the type system enforces it.

Actions IconButton.jsx IconButton.d.ts IconButton.md

Playground

Change a prop and watch it. The code below follows along.

IconButton — Variants, sizes and states.

Install and import

IconButton is in @dovetail-ds/react. Install it with React, if you haven't:

npm install @dovetail-ds/react react react-dom
import { IconButton } from "@dovetail-ds/react";

Import @dovetail-ds/react/styles.css once at your app's root, then your theme after it. Setup covers the stylesheets, fonts and dark mode.

Guidelines

A button whose only content is an icon. label is a required prop and the type system enforces it.

Use it when

  • Space is genuinely tight and the icon is unambiguous: close, search, more, edit, delete.

Don't use it when

  • The icon needs explaining. If you would add a caption, use a Button with iconStart.
  • It is the primary action in a form or dialog. Those get words.

Example

<IconButton label="Close dialog" onClick={close}>
  <X size={20} />
</IconButton>

Variants

ghost (default) for toolbars and table rows. solid for a floating or primary action.

Accessibility

label becomes both aria-label and the native tooltip. On touch surfaces use size="md" or larger: xs and sm fall below the 44px target and need a padded hit area around them.

Content

Label is a verb phrase naming the action and its object: "Close dialog", not "Close" or "X".

Props

The typed contract, from IconButton.d.ts.

import * as React from "react";

/** Icon-only button. The accessible label is required, not optional. */
export interface IconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  /** Accessible name and tooltip. Required — an unlabelled icon button is a bug. */
  label: string;
  /** @default "ghost" */
  variant?: "ghost" | "solid";
  /** @default "md" */
  size?: "xs" | "sm" | "md" | "lg";
  disabled?: boolean;
  /** A single icon, 20px, currentColor. */
  children: React.ReactNode;
}

export declare function IconButton(props: IconButtonProps): React.JSX.Element;

Tokens it reads

Every custom property IconButton.jsx resolves, read from the source: 12 component, 4 semantic. Component tier first, because that is the one to reach for.

  • component Its own tier. Override one of these and only this component moves.
  • semantic Shared roles. Override one and everything using that role moves with it.
TokenTierDeclared as
--dt-button-disabled-bgcomponentvar(--dt-surface-action-disabled)
--dt-button-disabled-fgcomponentvar(--dt-text-on-action-disabled)
--dt-button-ghost-bgcomponentvar(--dt-surface-action-ghost)
--dt-button-ghost-bg-activecomponentvar(--dt-surface-action-ghost-active)
--dt-button-ghost-bg-hovercomponentvar(--dt-surface-action-ghost-hover)
--dt-button-ghost-fgcomponentvar(--dt-text-on-action-ghost)
--dt-button-primary-bgcomponentvar(--dt-surface-action)
--dt-button-primary-bg-activecomponentvar(--dt-surface-action-active)
--dt-button-primary-bg-hovercomponentvar(--dt-surface-action-hover)
--dt-button-primary-fgcomponentvar(--dt-text-on-action)
--dt-button-radiuscomponentvar(--dt-radius-pill)
--dt-button-transitioncomponentvar(--dt-motion-micro)
--dt-size-control-lgsemanticvar(--dt-dim-12)
--dt-size-control-mdsemanticvar(--dt-dim-10)
--dt-size-control-smsemanticvar(--dt-dim-8)
--dt-size-control-xssemanticvar(--dt-dim-6)

Source

React only, no dependencies, every value a token.

import React from "react";

export function IconButton({ label, variant = "ghost", size = "md", disabled = false, children, style, ...rest }) {
  const [state, setState] = React.useState("idle");
  const solid = variant === "solid";
  const bg = disabled
    ? "var(--dt-button-disabled-bg)"
    : solid
      ? `var(--dt-button-primary-bg${state === "active" ? "-active" : state === "hover" ? "-hover" : ""})`
      : `var(--dt-button-ghost-bg${state === "active" ? "-active" : state === "hover" ? "-hover" : ""})`;
  return (
    <button
      type="button"
      aria-label={label}
      title={label}
      disabled={disabled}
      onMouseEnter={() => !disabled && setState("hover")}
      onMouseLeave={() => setState("idle")}
      onMouseDown={() => !disabled && setState("active")}
      onMouseUp={() => !disabled && setState("hover")}
      style={{
        display: "inline-flex", alignItems: "center", justifyContent: "center",
        width: `var(--dt-size-control-${size})`, height: `var(--dt-size-control-${size})`,
        padding: 0, border: 0, borderRadius: "var(--dt-button-radius)",
        background: bg,
        color: disabled ? "var(--dt-button-disabled-fg)" : solid ? "var(--dt-button-primary-fg)" : "var(--dt-button-ghost-fg)",
        cursor: disabled ? "not-allowed" : "pointer",
        transition: "background var(--dt-button-transition)",
        ...style,
      }}
      {...rest}
    >
      {children}
    </button>
  );
}

In context

IconButton also appears on the actions reference cards, alongside the rest of its family.