Split Button

A compound button that combines a primary action button with a dropdown menu toggle. The primary button fires an action, while the secondary caret button opens a menu of alternative actions.

import { SplitButton } from "@tenstorrent/vesper/split-button";

export default function SplitButtonDemo() {
  return (
    <SplitButton
      menuItems={[
        { text: "Save as draft", onSelect: () => {} },
        { text: "Save and publish", onSelect: () => {} },
      ]}
    >
      Save
    </SplitButton>
  );
}
Keep the content of the action button short (1-3 words) and descriptive of the action being taken, the same way you would for a Button. The menu items beside it should be variations on that action, not unrelated ones.

Options

PropTypeDescriptionDefault
childrenReactNodeThe content of the primary action button.—
menuItemsMenuItemProps[]The list of menu items rendered in the dropdown. Required.—
onClickMouseEventHandler<HTMLButtonElement>Callback fired when the primary action button is clicked.—
size"sm" | "md" | "lg"The size of both the action button and the menu toggle."md"
variant"subtle" | "contrast"The visual style variant applied to both buttons."subtle"
disabledbooleanWhen true, disables both buttons.false
menuButtonAriaLabelstringAn accessible aria-label for the menu toggle button."Toggle menu"
menuWidthnumberThe width of the dropdown menu in pixels. Scales with the base rem size.200
menuSide"top" | "bottom" | "left" | "right"The preferred side of the split button to render the dropdown menu against."bottom"
menuSideOffsetnumberThe distance in pixels from the split button to the dropdown menu.8
menuAlign"start" | "center" | "end"The alignment of the dropdown menu relative to the split button."start"
menuAlignOffsetnumberAn offset in pixels from the aligned edge of the split button.0
menuOpenbooleanControls the open state of the dropdown menu (controlled mode).—
defaultMenuOpenbooleanWhether the dropdown menu is open by default (uncontrolled mode).false
onMenuOpenChange(open: boolean) => voidCallback fired when the dropdown menu's open state changes.—

All other props are forwarded to the underlying <div> element that wraps the two buttons.

Use a SplitButton when one action is the obvious default, but closely related variations of it should stay within reach. When there is no default action to promote, reach for the Menu component on its own; when there are no alternatives to offer, reach for the Button component.

Examples

Basic usage

Render a SplitButton by giving it some children to label the action button, an onClick handler for that action, and the menuItems that make up its dropdown:

Last action: none

import { useState } from "react";

import { SplitButton } from "@tenstorrent/vesper/split-button";
import { Typography } from "@tenstorrent/vesper/typography";

export default function BasicSplitButton() {
  const [lastAction, setLastAction] = useState<string | null>(null);

  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        alignItems: "flex-start",
        gap: "var(--vesper-spacing-4)",
      }}
    >
      <SplitButton
        onClick={() => setLastAction("Save")}
        menuItems={[
          { text: "Save as draft", onSelect: () => setLastAction("Draft") },
          {
            text: "Save and publish",
            onSelect: () => setLastAction("Publish"),
          },
        ]}
      >
        Save
      </SplitButton>
      <Typography variant="copy-sm">
        Last action: {lastAction ?? "none"}
      </Typography>
    </div>
  );
}

Clicking the action button fires onClick without ever opening the menu, and clicking the caret button opens the menu without firing onClick.

menuItems takes the same items as the Menu component, so each one can render an icon, a description, and one of the "default", "selected", "danger", "locked", or "disabled" styles:

import { Copy, Download, Trash } from "@tenstorrent/vesper/icons";
import { SplitButton } from "@tenstorrent/vesper/split-button";

export default function SplitButtonMenuItems() {
  return (
    <SplitButton
      menuWidth={260}
      onClick={() => {}}
      menuItems={[
        {
          text: "Export as CSV",
          icon: <Download />,
          description: "Includes every visible column",
          onSelect: () => {},
        },
        {
          text: "Copy to clipboard",
          icon: <Copy />,
          onSelect: () => {},
        },
        {
          text: "Delete export",
          icon: <Trash />,
          style: "danger",
          onSelect: () => {},
        },
      ]}
    >
      Export
    </SplitButton>
  );
}

Variants

A SplitButton can be rendered in one of two variants, which is applied to both of its buttons at once:

VariantWhen to use
"contrast"Use when the action needs strong visual contrast against its surrounding content, such as the main action of a toolbar.
"subtle"Default variant. Use when the action should sit alongside surrounding content without dominating it, such as a row-level action in a table.
import { SplitButton } from "@tenstorrent/vesper/split-button";

const MENU_ITEMS = [
  { text: "Save as draft", onSelect: () => {} },
  { text: "Save and publish", onSelect: () => {} },
];

export default function SplitButtonVariants() {
  return (
    <div style={{ display: "flex", gap: "var(--vesper-spacing-4)" }}>
      <SplitButton variant="contrast" menuItems={MENU_ITEMS}>
        Contrast
      </SplitButton>
      <SplitButton variant="subtle" menuItems={MENU_ITEMS}>
        Subtle
      </SplitButton>
    </div>
  );
}

Different sizes

A SplitButton can be rendered in one of three sizes; "sm", "md", or "lg". The default size is "md".

import { SplitButton } from "@tenstorrent/vesper/split-button";

const MENU_ITEMS = [
  { text: "Save as draft", onSelect: () => {} },
  { text: "Save and publish", onSelect: () => {} },
];

export default function SplitButtonSizes() {
  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        gap: "var(--vesper-spacing-4)",
      }}
    >
      <SplitButton size="sm" menuItems={MENU_ITEMS}>
        Small
      </SplitButton>
      <SplitButton size="md" menuItems={MENU_ITEMS}>
        Medium
      </SplitButton>
      <SplitButton size="lg" menuItems={MENU_ITEMS}>
        Large
      </SplitButton>
    </div>
  );
}

Setting the menu width

The dropdown is 200px wide by default. Pass menuWidth when that is too narrow for its items, such as when they carry descriptions or unusually long labels. Like the menu offsets, this value scales with the base rem size:

import { SplitButton } from "@tenstorrent/vesper/split-button";

const MENU_ITEMS = [
  { text: "Export as CSV", onSelect: () => {} },
  { text: "Export as JSON", onSelect: () => {} },
];

export default function SplitButtonMenuWidth() {
  return (
    <SplitButton variant="subtle" menuWidth={320} menuItems={MENU_ITEMS}>
      320px width menu
    </SplitButton>
  );
}

Positioning the menu

The dropdown is positioned against the split button as a whole, not just the caret button that opens it, so a menu aligned to "end" lines up with the right edge of the action button. The menuSide, menuSideOffset, menuAlign, and menuAlignOffset props map onto the Menu component's side, sideOffset, align, and alignOffset props:

import { SplitButton } from "@tenstorrent/vesper/split-button";

const MENU_ITEMS = [
  { text: "Save as draft", onSelect: () => {} },
  { text: "Save and publish", onSelect: () => {} },
];

export default function SplitButtonMenuPlacement() {
  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        gap: "var(--vesper-spacing-4)",
      }}
    >
      <SplitButton menuItems={MENU_ITEMS}>Aligned to start</SplitButton>
      <SplitButton menuAlign="end" menuItems={MENU_ITEMS}>
        Aligned to end
      </SplitButton>
      <SplitButton menuSide="top" menuItems={MENU_ITEMS}>
        Above the button
      </SplitButton>
    </div>
  );
}

Controlling the menu's open state

By default, a SplitButton tracks its own menu state. Pass defaultMenuOpen to render with the menu already open, or use menuOpen and onMenuOpenChange together to own the state yourself:

Menu open state: false

import { useState } from "react";

import { SplitButton } from "@tenstorrent/vesper/split-button";
import { Typography } from "@tenstorrent/vesper/typography";

export default function ControlledSplitButtonMenu() {
  const [menuOpen, setMenuOpen] = useState(false);

  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        alignItems: "flex-start",
        gap: "var(--vesper-spacing-4)",
      }}
    >
      <SplitButton
        menuSide="top"
        menuOpen={menuOpen}
        onMenuOpenChange={setMenuOpen}
        onClick={() => {}}
        menuItems={[
          { text: "Save as draft", onSelect: () => {} },
          { text: "Save and publish", onSelect: () => {} },
        ]}
      >
        Save
      </SplitButton>
      <Typography variant="copy-sm">
        Menu open state: {String(menuOpen)}
      </Typography>
    </div>
  );
}

Labelling the menu toggle

The caret button has no visible text, so it is labelled "Toggle menu" for assistive technology by default. Pass menuButtonAriaLabel to override the default ARIA label:

import { SplitButton } from "@tenstorrent/vesper/split-button";

export default function LabelledSplitButton() {
  return (
    <SplitButton
      variant="subtle"
      menuButtonAriaLabel="Show export options"
      onClick={() => {}}
      menuItems={[
        { text: "Export as CSV", onSelect: () => {} },
        { text: "Export as JSON", onSelect: () => {} },
      ]}
    >
      Export
    </SplitButton>
  );
}

Disabling a split button

Pass disabled to disable both the action button and the caret button at the same time. The action button stops firing onClick, and the caret button no longer opens its menu:

import { SplitButton } from "@tenstorrent/vesper/split-button";

export default function DisabledSplitButton() {
  return (
    <SplitButton
      disabled
      onClick={() => {}}
      menuItems={[
        { text: "Save as draft", onSelect: () => {} },
        { text: "Save and publish", onSelect: () => {} },
      ]}
    >
      Save
    </SplitButton>
  );
}
There is no way to disable only one half of a SplitButton. When the alternatives are unavailable but the main action is not (or the other way around), render the individual items as "disabled" or "locked" menu items instead, so the reason stays visible to the user.