All components

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

Public profile

These primitives compose with native controls and form libraries.

Shown beside your activity and comments.

1"use client";
2
3import { 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";
15
16type ProfileValues = {
17 displayName: string;
18 email: string;
19};
20
21export function FormFieldsCompositionExample() {
22 const {
23 register,
24 handleSubmit,
25 formState: { errors },
26 } = useForm<ProfileValues>({
27 defaultValues: { displayName: "", email: "" },
28 });
29
30 return (
31 <form
32 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 profile
39 </h3>
40 <p className="text-sm text-muted-foreground">
41 These primitives compose with native controls and form libraries.
42 </p>
43 </div>
44
45 <FieldGroup>
46 <Field data-invalid={Boolean(errors.displayName)}>
47 <FieldLabel htmlFor="profile-display-name">Display name</FieldLabel>
48 <FieldControl>
49 <Input
50 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>
65
66 <Field data-invalid={Boolean(errors.email)}>
67 <FieldLabel htmlFor="profile-email">Email</FieldLabel>
68 <FieldControl>
69 <Input
70 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>
84
85 <Button type="submit" className="self-start">
86 Save profile
87 </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
1"use client";
2
3import { 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";
14
15const 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];
52
53export function ActionMenuDefaultExample() {
54 const [last, setLast] = useState<string>("Open the menu…");
55
56 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 ];
79
80 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 <ActionMenu
85 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>
95
96 <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 <li
104 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 <ActionMenu
109 actions={rowActions}
110 triggerLabel={`Actions for ${name}`}
111 trigger={
112 <Button
113 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

NameTypeDefaultDescription
actionsActionMenuEntry[]RequiredAction rows, `{ type: "separator" }`, or `{ type: "label", label }`; duplicate separators are collapsed
triggerLabelstring"Actions"Accessible label for the default more (⋯) trigger
triggerReactElementundefinedOptional 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 / onOpenChangeboolean / (open: boolean) => voidundefinedControlled open state