Skip to content

Quick actions slide in over the row's meta, the rest wait in a menu.

Menus & tooltips@base-ui/react

01Preview

Inbox

2 unread

  • 9:41
  • 8:02
  • Yesterday
  • Mon

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install @base-ui/react

03Usage

import { RowActions } from "@/components/ui/row-actions";

<RowActions
  label={thread.subject}
  trailing={thread.time}
  actions={[
    { id: "archive", label: "Archive", icon: <ArchiveIcon />, shortcut: "e", inline: true, onSelect: archive },
    { id: "snooze", label: "Snooze", icon: <ClockIcon />, shortcut: "h", inline: true, onSelect: snooze },
    { id: "forward", label: "Forward", icon: <ArrowIcon />, onSelect: forward },
    { id: "delete", label: "Delete", icon: <TrashIcon />, tone: "danger", separated: true, onSelect: remove },
  ]}
>
  <a href={thread.url} className="after:absolute after:inset-0">{thread.from}</a>
</RowActions>

04Source

"use client";
import { Menu } from "@base-ui/react/menu";
import { Fragment, useSyncExternalStore } from "react";
import { cn } from "@/lib/cn";
import { MoreH } from "@/lib/icons";
import { Tooltip, TooltipProvider, TooltipShortcut } from "@/components/ui/tooltip";

export type RowAction = {
  id: string;
  label: string;
  icon?: React.ReactNode;
  onSelect: () => void;
  /** Shown inline on hover and focus. Everything else lives in the menu; on touch, everything does. */
  inline?: boolean;
  /** A single key (e.g. "e") that runs the action while the row has focus, shown in tooltips and the menu. */
  shortcut?: string;
  tone?: "default" | "danger";
  disabled?: boolean;
  /** Draw a separator above this item in the menu. */
  separated?: boolean;
};

// Touch screens have no hover to reveal inline actions, so they move into the menu. The inline
// buttons hide with CSS (no hydration risk); the menu, which only renders once opened, asks JS.
const coarse = "(hover: none), (pointer: coarse)";
const subscribe = (cb: () => void) => {
  const mq = window.matchMedia(coarse);
  mq.addEventListener("change", cb);
  return () => mq.removeEventListener("change", cb);
};
function useTouch() {
  return useSyncExternalStore(subscribe, () => window.matchMedia(coarse).matches, () => false);
}

export type RowActionsProps = Omit<React.ComponentProps<"div">, "children"> & {
  /** The row's name, used to label its actions for screen readers: "Actions for Launch review". */
  label: string;
  actions: RowAction[];
  /** The row's content. Make its primary link or button cover the row with after:absolute after:inset-0. */
  children: React.ReactNode;
  /** Trailing meta (a time, a count) that gives way to the actions on hover instead of being pushed aside. */
  trailing?: React.ReactNode;
  /** Label of the overflow button. */
  menuLabel?: string;
};

/**
 * A list row whose actions slide in from the right when it's hovered or focused, over the
 * trailing meta rather than beside it, so nothing in the row reflows. A menu holds the rest.
 */
export function RowActions({ label, actions, children, trailing, menuLabel = "More actions", className, onKeyDown, ...rest }: RowActionsProps) {
  const touch = useTouch();
  const inline = actions.filter((a) => a.inline);
  const menu = touch ? actions : actions.filter((a) => !a.inline);

  return (
    <div
      data-slot="row"
      className={cn(
        "group/row relative isolate flex min-h-12 items-center gap-3 rounded-lg px-2.5 py-2 transition-colors duration-150",
        "hover:bg-fg/[0.035] focus-within:bg-fg/[0.035] has-data-popup-open:bg-fg/[0.035]",
        className,
      )}
      onKeyDown={(e) => {
        onKeyDown?.(e);
        if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey || e.key.length !== 1) return;
        if ((e.target as HTMLElement).closest("input,textarea,[contenteditable=true],[role=menu]")) return;
        const hit = actions.find((a) => a.shortcut?.toLowerCase() === e.key.toLowerCase() && !a.disabled);
        if (hit) {
          e.preventDefault();
          hit.onSelect();
        }
      }}
      {...rest}
    >
      <div className="min-w-0 flex-1">{children}</div>

      {/* One grid cell holds both the meta and the actions, so the row keeps its width either way. */}
      <div className="relative grid shrink-0 items-center justify-items-end pointer-coarse:flex pointer-coarse:gap-2">
        {trailing != null && (
          <div
            className={cn(
              "col-start-1 row-start-1 flex items-center text-[12px] text-fg-3 tabular transition-[opacity,translate] duration-150 ease-out-quart",
              "pointer-fine:group-hover/row:-translate-x-1 pointer-fine:group-hover/row:opacity-0",
              "pointer-fine:group-focus-within/row:-translate-x-1 pointer-fine:group-focus-within/row:opacity-0",
              "pointer-fine:group-has-data-popup-open/row:opacity-0",
              "motion-reduce:translate-none",
            )}
          >
            {trailing}
          </div>
        )}

        <div
          role="group"
          aria-label={`Actions for ${label}`}
          className={cn(
            "relative z-10 col-start-1 row-start-1 flex items-center gap-0.5",
            // Hidden until the row is hovered or focused; still in the tab order, so focusing an action reveals it.
            "pointer-fine:opacity-0 pointer-fine:group-hover/row:opacity-100 pointer-fine:group-focus-within/row:opacity-100 pointer-fine:group-has-data-popup-open/row:opacity-100",
            "transition-opacity duration-150",
          )}
        >
          <TooltipProvider delay={600}>
            {inline.map((a, i) => (
              <Tooltip key={a.id} content={a.label} shortcut={a.shortcut ? [a.shortcut] : undefined}>
                <button
                  type="button"
                  aria-label={a.label}
                  aria-keyshortcuts={a.shortcut?.toUpperCase()}
                  disabled={a.disabled}
                  onClick={a.onSelect}
                  // Buttons arrive from the right, nearest the edge first, 20ms apart; they leave together.
                  style={{ "--i": inline.length - i } as React.CSSProperties}
                  className={cn(
                    "relative hidden size-7 items-center justify-center rounded-md text-fg-3 outline-none pointer-fine:inline-flex",
                    "transition-[opacity,translate,background-color,color,scale] duration-200 ease-out-expo",
                    "translate-x-2 opacity-0 group-hover/row:translate-x-0 group-hover/row:opacity-100 group-focus-within/row:translate-x-0 group-focus-within/row:opacity-100",
                    "group-hover/row:[transition-delay:calc(var(--i)*20ms),calc(var(--i)*20ms),0ms,0ms,0ms]",
                    "hover:bg-fg/[0.07] hover:text-fg active:scale-[0.9] active:duration-75",
                    a.tone === "danger" && "hover:bg-danger-soft hover:text-danger",
                    "focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-0 focus-visible:outline-fg-3",
                    "disabled:pointer-events-none disabled:opacity-40",
                    "motion-reduce:translate-x-0 [&_svg]:size-4",
                  )}
                >
                  {a.icon}
                </button>
              </Tooltip>
            ))}
          </TooltipProvider>

          {menu.length > 0 && (
            <Menu.Root>
              <Menu.Trigger
                aria-label={menuLabel}
                className={cn(
                  "relative inline-flex size-7 items-center justify-center rounded-md text-fg-3 outline-none",
                  "transition-[background-color,color,scale] duration-150 hover:bg-fg/[0.07] hover:text-fg active:scale-[0.9] active:duration-75",
                  "data-popup-open:bg-fg/[0.09] data-popup-open:text-fg",
                  "focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-0 focus-visible:outline-fg-3",
                  "before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden",
                )}
              >
                <MoreH />
              </Menu.Trigger>
              <Menu.Portal>
                <Menu.Positioner side="bottom" align="end" sideOffset={4} collisionPadding={8} className="z-(--z-dropdown)">
                  <Menu.Popup
                    className={cn(
                      "min-w-44 max-w-(--available-width) rounded-xl border border-line-2 bg-raised p-1 text-fg shadow-pop outline-none",
                      "origin-(--transform-origin) transition-[opacity,scale,translate] duration-160 ease-out-expo",
                      "data-starting-style:scale-96 data-starting-style:opacity-0 data-starting-style:-translate-y-1",
                      "data-ending-style:scale-98 data-ending-style:opacity-0 data-ending-style:duration-100",
                      "data-instant:transition-none motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:translate-y-0",
                    )}
                  >
                    {menu.map((a, i) => (
                      <Fragment key={a.id}>
                        {i > 0 && (a.separated || (touch && i === inline.length)) && <Menu.Separator className="mx-2 my-1 h-px bg-line" />}
                        <Menu.Item
                          disabled={a.disabled}
                          onClick={a.onSelect}
                          className={cn(
                            "flex h-8 cursor-default select-none items-center gap-2.5 rounded-lg px-2 text-[13px] outline-none pointer-coarse:h-10 pointer-coarse:text-[14px]",
                            "transition-colors duration-75 data-highlighted:bg-fg/[0.06]",
                            "data-disabled:opacity-40 [&_svg]:size-4 [&_svg]:shrink-0",
                            a.tone === "danger" ? "text-danger data-highlighted:bg-danger-soft" : "text-fg [&_svg]:text-fg-3",
                          )}
                        >
                          {a.icon}
                          <span className="min-w-0 flex-1 truncate">{a.label}</span>
                          {a.shortcut && !touch && <TooltipShortcut keys={[a.shortcut]} className="mr-0 opacity-80" />}
                        </Menu.Item>
                      </Fragment>
                    ))}
                  </Menu.Popup>
                </Menu.Positioner>
              </Menu.Portal>
            </Menu.Root>
          )}
        </div>
      </div>
    </div>
  );
}

05Props

RowActions

PropTypeDefaultDescription
label*stringThe row's name. Labels its actions group for screen readers: "Actions for Launch review".
actions*RowAction[]Every action the row offers. Inline ones show on hover and focus; the rest, and on touch all of them, live in the menu.
children*ReactNodeThe row's content. Give its primary link or button after:absolute after:inset-0 so the whole row is the target.
trailingReactNodeMeta such as a time or count. Shares one grid cell with the actions and fades aside when they arrive, so the row never reflows.
menuLabelstring"More actions"Accessible name of the overflow button.

RowAction

PropTypeDefaultDescription
id*stringStable key.
label*stringVerb for the action. The inline button's name and tooltip, and the menu item's text.
onSelect*() => voidRuns the action.
iconReactNode16px icon. Required in practice for inline actions.
inlinebooleanfalseShow as an icon button on hover and focus.
shortcutstringA single key that runs the action while focus is in the row. Shown in tooltips and the menu.
tone"default" | "danger""default"Danger colors the menu item and the inline hover.
separatedbooleanfalseDraw a separator above it in the menu. Put destructive actions last, separated.
disabledbooleanfalseShown but not runnable.

06Notes

Behavior

  • The trailing meta and the actions share one grid cell, so the description truncates at the same width whether or not the row is hovered; nothing reflows as the actions arrive.
  • Actions stay visible while the row has focus or its menu is open, so moving into the menu never makes them vanish underneath you.
  • Single-key shortcuts run while focus is anywhere in the row, but never while typing in a field or navigating the open menu.
  • On touch screens there is no hover, so inline actions move into the menu (behind a separator) with 40px rows, and the meta stays visible beside the menu button.

Motion

  • Inline buttons slide 8px in from the right and fade, 200ms ease-out-expo, the one nearest the edge first, 20ms apart; they leave together with no stagger.
  • The trailing meta drifts 4px left and fades in 150ms as they arrive. The menu grows from the button (scale 0.96, 160ms) and closes in 100ms.
  • Keyboard focus reveals everything without the stagger. Reduced motion keeps the fades and drops the travel.

Accessibility

  • Actions sit in a labeled group after the row's primary control, in tab order; hover-only visibility never hides them from the keyboard.
  • Inline buttons carry aria-label and aria-keyshortcuts, with a tooltip showing the key. The overflow is a menu with arrow keys, typeahead and Escape returning focus to it.