Action Menu
Shadcn dropdown preset for item actions: icons, shortcuts, destructive items, labels, and separators.
Composition with react-hook-form
Compose labels, descriptions, errors, and controls without coupling field state to the component library
composition-example.tsx
1"use client";23import { useForm } from "react-hook-form";4import {5 Field,6 FieldControl,7 FieldDescription,8 FieldError,9 FieldGroup,10 FieldLabel,11 FormSection,12} from "@/registry/ui";13import { Button } from "@/components/ui/button";14import { Input } from "@/components/ui/input";1516type ProfileValues = {17 displayName: string;18 email: string;19};2021export function FormFieldsCompositionExample() {22 const {23 register,24 handleSubmit,25 formState: { errors },26 } = useForm<ProfileValues>({27 defaultValues: { displayName: "", email: "" },28 });2930 return (31 <form32 className="mx-auto w-full max-w-lg"33 onSubmit={handleSubmit(() => undefined)}34 >35 <FormSection aria-labelledby="profile-section-title">36 <div className="flex flex-col gap-1">37 <h3 id="profile-section-title" className="font-medium">38 Public profile39 </h3>40 <p className="text-sm text-muted-foreground">41 These primitives compose with native controls and form libraries.42 </p>43 </div>4445 <FieldGroup>46 <Field data-invalid={Boolean(errors.displayName)}>47 <FieldLabel htmlFor="profile-display-name">Display name</FieldLabel>48 <FieldControl>49 <Input50 id="profile-display-name"51 aria-invalid={Boolean(errors.displayName)}52 aria-describedby="profile-display-name-description profile-display-name-error"53 {...register("displayName", {54 required: "Enter a display name.",55 })}56 />57 </FieldControl>58 <FieldDescription id="profile-display-name-description">59 Shown beside your activity and comments.60 </FieldDescription>61 <FieldError id="profile-display-name-error">62 {errors.displayName?.message}63 </FieldError>64 </Field>6566 <Field data-invalid={Boolean(errors.email)}>67 <FieldLabel htmlFor="profile-email">Email</FieldLabel>68 <FieldControl>69 <Input70 id="profile-email"71 type="email"72 aria-invalid={Boolean(errors.email)}73 aria-describedby="profile-email-error"74 {...register("email", {75 required: "Enter an email address.",76 })}77 />78 </FieldControl>79 <FieldError id="profile-email-error">80 {errors.email?.message}81 </FieldError>82 </Field>83 </FieldGroup>8485 <Button type="submit" className="self-start">86 Save profile87 </Button>88 </FormSection>89 </form>90 );91}
Default
Default trigger plus a compact list row pattern.
Default more trigger
Open the menu…NameActions
- Quarterly report
- Design tokens
- API schema
default-example.tsx
1"use client";23import { useState } from "react";4import {5 Copy,6 ExternalLink,7 MoreHorizontal,8 Pencil,9 Share2,10 Trash2,11} from "lucide-react";12import { Button } from "@/components/ui/button";13import { ActionMenu, type ActionMenuEntry } from "@/registry/ui";1415const rowActions: ActionMenuEntry[] = [16 {17 id: "edit",18 label: "Edit",19 icon: <Pencil className="size-4" />,20 onSelect: () => {},21 },22 {23 id: "duplicate",24 label: "Duplicate",25 icon: <Copy className="size-4" />,26 onSelect: () => {},27 },28 {29 id: "share",30 label: "Share",31 icon: <Share2 className="size-4" />,32 shortcut: "⌘S",33 onSelect: () => {},34 },35 { type: "separator" },36 {37 id: "open",38 label: "Open in new tab",39 icon: <ExternalLink className="size-4" />,40 onSelect: () =>41 window.open("https://example.com", "_blank", "noopener,noreferrer"),42 },43 { type: "separator" },44 {45 id: "delete",46 label: "Delete",47 icon: <Trash2 className="size-4" />,48 variant: "destructive",49 onSelect: () => {},50 },51];5253export function ActionMenuDefaultExample() {54 const [last, setLast] = useState<string>("Open the menu…");5556 const actions: ActionMenuEntry[] = [57 { type: "label", label: "Document" },58 {59 id: "edit",60 label: "Edit",61 icon: <Pencil className="size-4" />,62 onSelect: () => setLast("Edit"),63 },64 {65 id: "duplicate",66 label: "Duplicate",67 icon: <Copy className="size-4" />,68 onSelect: () => setLast("Duplicate"),69 },70 { type: "separator" },71 {72 id: "delete",73 label: "Delete",74 icon: <Trash2 className="size-4" />,75 variant: "destructive",76 onSelect: () => setLast("Delete"),77 },78 ];7980 return (81 <div className="flex flex-col gap-6">82 <div className="flex flex-wrap items-center gap-4">83 <p className="text-muted-foreground text-sm">Default more trigger</p>84 <ActionMenu85 actions={actions}86 triggerLabel="Document actions"87 onOpenChange={(o) => {88 if (o) setLast("Menu opened");89 }}90 />91 <span className="text-sm tabular-nums text-muted-foreground">92 {last}93 </span>94 </div>9596 <div className="bg-card max-w-md rounded-lg border">97 <div className="text-muted-foreground flex items-center justify-between border-b px-3 py-2 text-xs font-medium uppercase tracking-wide">98 <span>Name</span>99 <span className="w-10 text-center">Actions</span>100 </div>101 <ul className="divide-y">102 {["Quarterly report", "Design tokens", "API schema"].map((name) => (103 <li104 key={name}105 className="flex items-center justify-between gap-2 px-3 py-2.5 text-sm"106 >107 <span className="min-w-0 truncate font-medium">{name}</span>108 <ActionMenu109 actions={rowActions}110 triggerLabel={`Actions for ${name}`}111 trigger={112 <Button113 type="button"114 variant="ghost"115 size="icon"116 className="size-8 shrink-0"117 aria-label={`Actions for ${name}`}118 >119 <MoreHorizontal className="size-4" />120 </Button>121 }122 />123 </li>124 ))}125 </ul>126 </div>127 </div>128 );129}
Installation & source
Install via the shadcn CLI or copy the registry files manually.
bash
npx shadcn@latest add @tt-ui/action-menu
Props
| Name | Type | Default | Description |
|---|---|---|---|
| actions | ActionMenuEntry[] | Required | Action rows, `{ type: "separator" }`, or `{ type: "label", label }`; duplicate separators are collapsed |
| triggerLabel | string | "Actions" | Accessible label for the default more (⋯) trigger |
| trigger | ReactElement | undefined | Optional custom trigger (single element, `asChild`) |
| align | "start" | "center" | "end" | "end" | Menu alignment relative to trigger |
| side | "top" | "right" | "bottom" | "left" | "bottom" | Preferred side for the menu |
| open / onOpenChange | boolean / (open: boolean) => void | undefined | Controlled open state |