Skip to content

Row Actions

Row Actions keeps the commands and destinations for one application record together. Frequent destinations can remain visible. Secondary actions can sit behind one compact overflow trigger. Every item is still the real anchor, Boring Stack Link, or button that the application intended.

It is one component, not a family of RowAction, RowActionItem, or RowActionTrigger wrappers. There is no action schema, permission callback, visual variant, mutation client, or confirmation prop. The caller writes ordinary semantic markup and styles it with Tailwind.

ServiceActions.vue
Hagfish keeps invoice actions graphic and direct. Slipway keeps service actions quiet and operational. The component contract stays the same.

Installation

The command detects Vue, React, or Svelte, copies the matching source, and adds its Menu and Popover dependencies into the same conventional UI directory.

Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.

Terminal
npx klean-ui add row-actions

  • No initializer or configuration file
  • No framework, alias, or theme questions
  • No Klean runtime dependency

There is no initializer, provider, klean-ui.json, class helper, barrel file, or runtime Klean dependency.

Usage

Keep the most frequent action visible when that genuinely saves work. Put secondary commands and destinations in the overflow content.

Vue

ServiceActions.vue
<script setup>
import { Link, router } from '@inertiajs/vue3'
import RowActions from '@/components/ui/row-actions/RowActions.vue'

defineProps({ service: Object, busy: Boolean })

function redeploy(service) {
  router.post(
    `/services/${service.id}/deployments`,
    {},
    { preserveScroll: true }
  )
}
</script>

<template>
  <RowActions :label="`Actions for ${service.name}`" :busy="busy">
    <Link :href="`/services/${service.id}/logs`">Logs</Link>

    <template #menu>
      <Link :href="`/services/${service.id}/settings`">Settings</Link>
      <button type="button" @click="redeploy(service)">Redeploy</button>
      <button
        type="button"
        command="show-modal"
        :commandfor="`delete-${service.id}`"
      >
        Delete service
      </button>
    </template>
  </RowActions>
</template>

React

ServiceActions.jsx
import { Link, router } from '@inertiajs/react'
import RowActions from '@/components/ui/row-actions/RowActions.jsx'

export function ServiceActions({ service, busy }) {
  return (
    <RowActions
      label={`Actions for ${service.name}`}
      busy={busy}
      menu={
        <>
          <Link href={`/services/${service.id}/settings`}>Settings</Link>
          <button
            type="button"
            onClick={() =>
              router.post(
                `/services/${service.id}/deployments`,
                {},
                { preserveScroll: true }
              )
            }
          >
            Redeploy
          </button>
          <button
            type="button"
            command="show-modal"
            commandFor={`delete-${service.id}`}
          >
            Delete service
          </button>
        </>
      }
    >
      <Link href={`/services/${service.id}/logs`}>Logs</Link>
    </RowActions>
  )
}

Svelte

ServiceActions.svelte
<script>
  import { Link, router } from '@inertiajs/svelte'
  import RowActions from '@/components/ui/row-actions/RowActions.svelte'

  let { service, busy = false } = $props()

  function redeploy() {
    router.post(
      `/services/${service.id}/deployments`,
      {},
      { preserveScroll: true }
    )
  }
</script>

{#snippet visibleActions()}
  <Link href={`/services/${service.id}/logs`}>Logs</Link>
{/snippet}

{#snippet overflowActions()}
  <Link href={`/services/${service.id}/settings`}>Settings</Link>
  <button type="button" onclick={redeploy}>Redeploy</button>
  <button
    type="button"
    command="show-modal"
    commandfor={`delete-${service.id}`}
  >
    Delete service
  </button>
{/snippet}

<RowActions
  label={`Actions for ${service.name}`}
  {busy}
  children={visibleActions}
  menu={overflowActions}
/>

API

PurposeVueReactSvelte
Visible actionsdefault slotchildrenchildren snippet
Overflow actions#menu slotmenumenu snippet
Trigger contents#trigger slottriggertrigger snippet
Accessible namelabellabellabel
Pending operationbusybusybusy
Stable overflow IDididid
Preferred positionplacementplacementplacement
Trigger gapoffsetoffsetoffset
Root stylingclassclassNameclass

label defaults to “Actions.” Use a record-specific label such as “Actions for invoice INV-1042” whenever several action groups appear on the page. placement defaults to bottom-end, offset defaults to 4, and collision handling may move the menu when the preferred side has no room.

If no overflow content is supplied, Row Actions renders no trigger or menu. If overflow exists, the default trigger is a compact ellipsis button. Replace only its visual contents when the application already has an established icon treatment; Row Actions keeps the button semantics and relationship.

Durable application behavior

Navigation stays navigation. Use a native anchor or framework-native Inertia Link so URLs remain openable in a new tab, copyable, server-renderable, and recoverable after reload. Row Actions does not convert destinations into click callbacks.

Commands stay buttons. The application owns the router request, processing state, result message, authorization, and server validation. Pass busy while an operation makes another overflow choice unsafe; the menu closes and its trigger becomes unavailable, while caller-owned visible actions remain truthful rather than being silently disabled.

Row Actions stops pointer and click propagation at its root so an action inside a clickable table row does not also activate the row. It does not prevent the link or button's own default behavior.

Permissions and destructive actions

Render only actions the current response authorizes. Ordinary framework conditionals are clearer than a component permission language, and the server still enforces the operation.

A destructive menu item should open a Dialog that names the record and consequence. The Dialog owns confirmation and focus containment; the application owns the request and pending state. Do not make the first click delete the record, and do not ask Row Actions to guess which commands are destructive.

Keyboard and accessibility

  • The root is a named group, so repeated action sets remain distinguishable.
  • Visible links and buttons keep their native Tab behavior.
  • The overflow trigger exposes its menu relationship and expanded state.
  • Arrow Down or Arrow Up on the trigger opens the menu at the first or last enabled item.
  • Inside the menu, Arrow keys, Home, End, typeahead, Tab, and Escape follow the established Menu contract.
  • Selecting an item closes the menu and restores trigger focus when appropriate.
  • Disabled or aria-disabled menu items are skipped and cannot accidentally activate.
  • busy is exposed on the group and disables only the overflow trigger.
  • The caller supplies complete visible labels, focus-visible Tailwind classes, and any live result message.

Styling with Tailwind

Row Actions supplies only a compact inline layout and a neutral trigger. class or className merges onto the group. The visible actions, overflow items, and optional trigger contents are caller markup, so Tailwind is their entire visual API.

There is no variant, tone, size, destructive, itemClass, or product theme prop. When several rows share one treatment, create a small application component around the copied source and the product's ordinary classes.

When to use

Use Row Actions for one record in a Table, DataTable, card list, invoice list, member list, deployment history, or similar repeated application surface. It is especially useful when one common destination should stay visible and less frequent choices need compact overflow.

When not to use

  • Use a plain Button or Link when the record has only one action.
  • Use Menu directly when the trigger is not part of a repeated row action group.
  • Use Command for application-wide search and command discovery.
  • Use a visible page toolbar for actions that apply to the whole result rather than one record.
  • Do not hide the row's only important task behind an ellipsis merely to make the layout sparse.

Complete framework source

Vue

RowActions.vue
<script setup>
import { computed, ref, useAttrs, useId, useSlots, watch } from 'vue'
import { twMerge } from 'tailwind-merge'
import Menu from '../menu/Menu.vue'

defineOptions({ inheritAttrs: false })

const props = defineProps({
  /** Accessible name for this row's actions and overflow menu. */
  label: { type: String, default: 'Actions' },
  /** Prevents duplicate overflow interaction while application work is pending. */
  busy: { type: Boolean, default: false },
  /** Optional stable id for the overflow menu. */
  id: { type: String, default: undefined },
  /** Preferred logical menu placement. Collision handling may flip it. */
  placement: { type: String, default: 'bottom-end' },
  /** Space in pixels between the trigger and menu. */
  offset: { type: Number, default: 4 }
})

const attrs = useAttrs()
const slots = useSlots()
const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, '')
const menuId = computed(() => props.id ?? `klean-row-actions-${generatedId}`)
const open = ref(false)
const hasMenu = computed(() => Boolean(slots.menu))
const rootAttrs = computed(() => {
  const {
    class: _class,
    role: _role,
    'aria-label': _ariaLabel,
    'aria-busy': _ariaBusy,
    'data-slot': _dataSlot,
    ...rest
  } = attrs
  return rest
})

function stopPropagation(event) {
  event.stopPropagation()
}

watch(
  () => props.busy,
  (busy) => {
    if (busy) open.value = false
  }
)
</script>

<template>
  <div
    v-bind="rootAttrs"
    role="group"
    :aria-label="label"
    :aria-busy="busy ? 'true' : undefined"
    data-slot="row-actions"
    :class="twMerge('inline-flex items-center gap-1', attrs.class)"
    @pointerdown="stopPropagation"
    @click="stopPropagation"
  >
    <slot />

    <button
      v-if="hasMenu"
      type="button"
      :disabled="busy"
      :aria-label="label"
      :aria-controls="menuId"
      :aria-expanded="open ? 'true' : 'false'"
      :popovertarget="menuId"
      data-slot="row-actions-trigger"
      class="inline-grid size-9 cursor-pointer place-items-center rounded-md text-current hover:bg-black/5 focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
    >
      <slot name="trigger">
        <svg
          class="size-4"
          viewBox="0 0 20 20"
          fill="currentColor"
          aria-hidden="true"
        >
          <path
            d="M6 10a2 2 0 1 1-4 0 2 2 0 0 1 4 0Zm6 0a2 2 0 1 1-4 0 2 2 0 0 1 4 0Zm6 0a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"
          />
        </svg>
      </slot>
    </button>

    <Menu
      v-if="hasMenu"
      :id="menuId"
      v-model:open="open"
      :aria-label="label"
      :placement="placement"
      :offset="offset"
      data-row-actions-menu=""
      class="min-w-40"
      v-slot="{ close }"
    >
      <slot name="menu" :close="close" />
    </Menu>
  </div>
</template>

React

RowActions.jsx
import { useEffect, useId, useState } from 'react'
import { twMerge } from 'tailwind-merge'
import Menu from '../menu/Menu.jsx'

export default function RowActions({
  label = 'Actions',
  busy = false,
  id,
  placement = 'bottom-end',
  offset = 4,
  className,
  children,
  menu,
  trigger,
  onClick,
  onPointerDown,
  ...rootProps
}) {
  const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, '')
  const menuId = id ?? `klean-row-actions-${generatedId}`
  const [open, setOpen] = useState(false)

  useEffect(() => {
    if (busy) setOpen(false)
  }, [busy])

  function handleClick(event) {
    event.stopPropagation()
    onClick?.(event)
  }

  function handlePointerDown(event) {
    event.stopPropagation()
    onPointerDown?.(event)
  }

  return (
    <div
      {...rootProps}
      role="group"
      aria-label={label}
      aria-busy={busy || undefined}
      data-slot="row-actions"
      className={twMerge('inline-flex items-center gap-1', className)}
      onPointerDown={handlePointerDown}
      onClick={handleClick}
    >
      {children}

      {menu ? (
        <>
          <button
            type="button"
            disabled={busy}
            aria-label={label}
            aria-controls={menuId}
            aria-expanded={open}
            popoverTarget={menuId}
            data-slot="row-actions-trigger"
            className="inline-grid size-9 cursor-pointer place-items-center rounded-md text-current hover:bg-black/5 focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
          >
            {trigger ?? (
              <svg
                className="size-4"
                viewBox="0 0 20 20"
                fill="currentColor"
                aria-hidden="true"
              >
                <path d="M6 10a2 2 0 1 1-4 0 2 2 0 0 1 4 0Zm6 0a2 2 0 1 1-4 0 2 2 0 0 1 4 0Zm6 0a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z" />
              </svg>
            )}
          </button>
          <Menu
            id={menuId}
            open={open}
            onOpenChange={setOpen}
            aria-label={label}
            placement={placement}
            offset={offset}
            data-row-actions-menu=""
            className="min-w-40"
          >
            {menu}
          </Menu>
        </>
      ) : null}
    </div>
  )
}

Svelte

RowActions.svelte
<script>
  import { twMerge } from "tailwind-merge";
  import Menu from "../menu/Menu.svelte";

  const componentIdentity = $props.id();
  const generatedId = componentIdentity.replace(/[^a-zA-Z0-9_-]/g, "");
  let {
    label = "Actions",
    busy = false,
    id,
    placement = "bottom-end",
    offset = 4,
    class: className = "",
    children,
    menu,
    trigger,
    onclick,
    onpointerdown,
    "data-slot": _dataSlot,
    ...rootProps
  } = $props();

  let open = $state(false);
  let menuId = $derived(id ?? `klean-row-actions-${generatedId}`);

  $effect(() => {
    if (busy) open = false;
  });

  function handleClick(event) {
    event.stopPropagation();
    onclick?.(event);
  }

  function handlePointerDown(event) {
    event.stopPropagation();
    onpointerdown?.(event);
  }
</script>

{#snippet menuContent(context)}
  {@render menu?.(context)}
{/snippet}

<div
  {...rootProps}
  role="group"
  aria-label={label}
  aria-busy={busy || undefined}
  data-slot="row-actions"
  class={twMerge("inline-flex items-center gap-1", className)}
  onclick={handleClick}
  onpointerdown={handlePointerDown}
>
  {@render children?.()}

  {#if menu}
    <button
      type="button"
      disabled={busy}
      aria-label={label}
      aria-controls={menuId}
      aria-expanded={open}
      popovertarget={menuId}
      data-slot="row-actions-trigger"
      class="inline-grid size-9 cursor-pointer place-items-center rounded-md text-current hover:bg-black/5 focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
    >
      {#if trigger}
        {@render trigger()}
      {:else}
        <svg
          class="size-4"
          viewBox="0 0 20 20"
          fill="currentColor"
          aria-hidden="true"
        >
          <path
            d="M6 10a2 2 0 1 1-4 0 2 2 0 0 1 4 0Zm6 0a2 2 0 1 1-4 0 2 2 0 0 1 4 0Zm6 0a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"
          />
        </svg>
      {/if}
    </button>
    <Menu
      id={menuId}
      bind:open
      aria-label={label}
      {placement}
      {offset}
      data-row-actions-menu=""
      class="min-w-40"
      children={menuContent}
    />
  {/if}
</div>

  • DataTable — coordinates server-owned rows, sorting, selection, and pagination around per-row actions.
  • Table — provides the semantic table markup that may contain action cells.
  • Menu — supplies overflow keyboard behavior and truthful link/button items.
  • Button — represents a visible row command.
  • Dialog — confirms destructive row operations.
  • Tooltip — supplements a terse icon trigger when visible text cannot fit.

All open source projects are released under the MIT License.