Skip to content

Row Actions ​

Keep actions for one record together. Show frequent actions directly and place secondary actions behind an overflow trigger, using real buttons and links throughout.

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.

Preview Live · Vue
Usage
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>
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
<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}
/>
View source ServiceActions.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>

Keep frequent actions visible and move less common choices into the overflow menu.

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 from your application’s directory. Klean detects your framework and copies the matching source.

Terminal
npx klean-ui add row-actions

Source you own. Edit the installed files to make them yours.

Usage ​

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

Copy the example above for your framework.

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.

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
<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>
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
<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.