Skip to content
goodtabs UI
BrowseBrowse components

Toolbar

A row of icon buttons in groups with hairlines, tooltips and arrow key focus.

npx shadcn@latest add https://ui.goodtabs.co/r/toolbar.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/toolbar

Text formatting in three groups. Tab in once, then use the arrow keys.

With pressed state

Toggles that report aria-pressed. Bold starts on.

Compact size

The sm size with 28px buttons, one of them disabled.

Usage

import { Toolbar, ToolbarButton, ToolbarGroup, ToolbarSeparator } from "@/components/ui/toolbar"
import { BoldIcon, ItalicIcon, LinkIcon } from "lucide-react"

<Toolbar label="Formatting">
  <ToolbarGroup aria-label="Style">
    <ToolbarButton label="Bold" pressed={bold} onClick={toggleBold}>
      <BoldIcon />
    </ToolbarButton>
    <ToolbarButton label="Italic">
      <ItalicIcon />
    </ToolbarButton>
  </ToolbarGroup>
  <ToolbarSeparator />
  <ToolbarButton label="Add link">
    <LinkIcon />
  </ToolbarButton>
</Toolbar>

Props

PropTypeDefaultDescription
labelstringAccessible name. On Toolbar it names the toolbar; on ToolbarButton it is the aria-label and tooltip text. Required on both.
size"sm" | "default""default"Button size on Toolbar: 28px or 32px. Every ToolbarButton inside follows it.
pressedbooleanOn ToolbarButton: marks a toggle as on, with aria-pressed and the primary fill.
childrenReactNodeOn ToolbarButton: the icon. On Toolbar: groups, buttons and separators.

Source

toolbar/toolbar.tsx
"use client";

import * as React from "react";
import { Toolbar as ToolbarPrimitive } from "radix-ui";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";

type Size = "sm" | "default";

const SizeContext = React.createContext<Size>("default");

type ToolbarProps = Omit<React.ComponentProps<typeof ToolbarPrimitive.Root>, "aria-label" | "orientation"> & {
  /** Accessible name of the toolbar, for example "Formatting". */
  label: string;
  /** Button size: 28px or 32px. */
  size?: Size;
};

/**
 * A row of icon buttons in groups divided by hairlines. One tab stop: arrow keys move
 * between buttons, Home and End jump to the ends. Compose it from ToolbarGroup, ToolbarButton and ToolbarSeparator.
 */
function Toolbar({ label, size = "default", className, ...props }: ToolbarProps) {
  return (
    <SizeContext.Provider value={size}>
      <TooltipProvider>
        <ToolbarPrimitive.Root
          aria-label={label}
          orientation="horizontal"
          data-slot="toolbar"
          data-size={size}
          className={cn("inline-flex max-w-full items-center gap-px rounded-lg border bg-background p-1", className)}
          {...props}
        />
      </TooltipProvider>
    </SizeContext.Provider>
  );
}

/** A set of related buttons. Pass aria-label to name the group. */
function ToolbarGroup({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div role="group" data-slot="toolbar-group" className={cn("flex items-center gap-px", className)} {...props} />
  );
}

type ToolbarButtonProps = Omit<
  React.ComponentProps<typeof Button>,
  "aria-label" | "aria-pressed" | "variant" | "size"
> & {
  /** Accessible name. Becomes the aria-label and the tooltip text. */
  label: string;
  /** Marks a toggle as on, with aria-pressed. */
  pressed?: boolean;
};

/** A ghost icon button with a tooltip. The icon goes in children. */
function ToolbarButton({ label, pressed, type = "button", className, children, ...props }: ToolbarButtonProps) {
  const size = React.useContext(SizeContext);
  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <ToolbarPrimitive.Button asChild>
          <Button
            type={type}
            variant="ghost"
            size={size === "sm" ? "icon-sm" : "icon"}
            aria-label={label}
            aria-pressed={pressed}
            data-pressed={pressed || undefined}
            data-slot="toolbar-button"
            className={cn(
              "rounded-sm",
              pressed && "bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground",
              className,
            )}
            {...props}
          >
            {children}
          </Button>
        </ToolbarPrimitive.Button>
      </TooltipTrigger>
      <TooltipContent>{label}</TooltipContent>
    </Tooltip>
  );
}

/** A short vertical hairline between groups. */
function ToolbarSeparator({ className, ...props }: Omit<React.ComponentProps<typeof Separator>, "orientation">) {
  return (
    <Separator
      orientation="vertical"
      data-slot="toolbar-separator"
      className={cn("mx-1 data-vertical:h-4 data-vertical:self-center", className)}
      {...props}
    />
  );
}

export { Toolbar, ToolbarGroup, ToolbarButton, ToolbarSeparator };

For AI agents

Hand your agent this page, the markdown at /components/toolbar/prompt.md, or the Copy AI prompt button above.

When to use

  • Editor and canvas controls: formatting, alignment, zoom, history
  • A floating action row above a selection or at the top of a panel
  • Any set of five or more icon actions that belong together and should be one tab stop

When not to use

  • For two or three standalone actions; use icon buttons on their own
  • For mutually exclusive choices with a visible selection; use a toggle group or segmented tabs
  • For navigation between pages; use tabs or a navbar

Agent notes

  • It is radix Toolbar underneath: one tab stop, arrow keys move between buttons, Home and End jump, disabled buttons are skipped
  • ToolbarButton is always ghost and sized by the Toolbar; pass onClick from a client component and pressed for toggles
  • Toolbar wraps its own TooltipProvider, so tooltips need no setup; separators are decorative
  • The frame is rounded-lg with a hairline and 4px padding; pass className="border-0 p-0" to drop it inside another bar