Primitives
Trigger Menu
BetaBeta — the props contract is close to final, but small breaking changes can still land.Caret-anchored command or mention menu for text editors.
Composition
Grouped suggestions
- <TriggerMenu>
- <TriggerMenuList>
- <TriggerMenuEmpty />
- <TriggerMenuGroup>
- <TriggerMenuGroupLabel />
- <TriggerMenuItem>
- <TriggerMenuIcon />
- <TriggerMenuList>
Installation
First install and activate one skin. Core deliberately contains no visual token defaults.
The Control UI source installs this primitive from src/registry/sources/control-ui/ui/trigger-menu.tsx.
npx shadcn@latest add https://control-ui.dev/r/trigger-menu.jsonDependencies
Textarea binding
src/registry/hooks/use-textarea-trigger-menu.tsSupportTrigger menu engine
src/registry/hooks/use-trigger-menu.tsSupportTrigger detection
src/registry/lib/trigger-detect.tsSupportSurface variants
Scroll areasrc/registry/sources/control-ui/surface-variants.tsSupportscroll-areaPrimitiveRaw code
Primary installed source
"use client";import { Popover as PopoverPrimitive } from "@base-ui/react/popover";import type { ComponentProps, CSSProperties, ReactNode } from "react";import type { OpenChangeEventDetails } from "@/components/control-ui/control-props";import type { PopupKnobStyle } from "@/components/control-ui/knob-contracts/popup-knobs";import { cn } from "@/components/control-ui/lib/cn";import { controlEffectsAttribute } from "@/components/control-ui/skin";import { useSkin } from "@/components/control-ui/skin-provider";import { popupItemStructureClasses } from "@/components/control-ui/surface-variants";import { ScrollArea } from "@/components/control-ui/ui/scroll-area";export type TriggerMenuProps = { open: boolean; onOpenChange?: (open: boolean, eventDetails: OpenChangeEventDetails) => void; anchorRect: DOMRect | null; side?: "top" | "bottom"; align?: "start" | "center" | "end"; sideOffset?: number; className?: string; children?: ReactNode;} & { style?: CSSProperties & PopupKnobStyle };export type TriggerMenuListProps = ComponentProps<"div"> & { style?: CSSProperties & PopupKnobStyle };export type TriggerMenuItemProps = Omit< Omit<ComponentProps<"div">, "onSelect"> & { active?: boolean; disabled?: boolean; }, "style"> & { style?: CSSProperties & PopupKnobStyle };export type TriggerMenuEmptyProps = Omit<ComponentProps<"div">, "style"> & { style?: CSSProperties & PopupKnobStyle };export type TriggerMenuGroupProps = ComponentProps<"div"> & { style?: CSSProperties & PopupKnobStyle };export type TriggerMenuGroupLabelProps = Omit<ComponentProps<"div">, "style"> & { style?: CSSProperties & PopupKnobStyle };export type TriggerMenuIconProps = Omit<ComponentProps<"span">, "style"> & { style?: CSSProperties & PopupKnobStyle };export function TriggerMenu({ open, onOpenChange, anchorRect, side = "top", align = "start", sideOffset = 8, className, children,}: TriggerMenuProps) { const skin = useSkin(); return ( <PopoverPrimitive.Root open={open} onOpenChange={(next, eventDetails) => onOpenChange?.(next, eventDetails)} modal={false}> <PopoverPrimitive.Portal> <PopoverPrimitive.Positioner data-control-ui="trigger-menu" data-popup-kind="trigger-menu" data-control-family="popup" data-slot="positioner" data-skin={skin.id} data-effects={controlEffectsAttribute(skin.effects)} anchor={anchorRect === null ? undefined : () => ({ getBoundingClientRect: () => anchorRect })} side={side} align={align} sideOffset={sideOffset} className="z-(--z-popup)" > <PopoverPrimitive.Popup data-control-ui="trigger-menu" data-popup-kind="trigger-menu" data-control-family="popup" data-slot="root" data-surface="floating" data-popup-part="list-surface" initialFocus={false} finalFocus={false} className={cn( "grid max-h-[min(18rem,var(--available-height))] w-64 max-w-[var(--available-width)] grid-rows-[minmax(0,1fr)]", className, )} > <ScrollArea lockAxis="x">{children}</ScrollArea> </PopoverPrimitive.Popup> </PopoverPrimitive.Positioner> </PopoverPrimitive.Portal> </PopoverPrimitive.Root> );}export function TriggerMenuList({ className, ...props }: TriggerMenuListProps) { return ( <div data-control-ui="trigger-menu" data-popup-kind="trigger-menu" data-slot="list" data-control-family="popup" data-popup-part="list-content" role="listbox" className={cn("flex flex-col", className)} {...props} /> );}export function TriggerMenuItem({ className, active = false, disabled = false, onMouseDown, ...props }: TriggerMenuItemProps) { return ( <div data-control-ui="trigger-menu" data-popup-kind="trigger-menu" data-control-family="popup" data-slot="item" data-popup-part="item" role="option" tabIndex={-1} aria-selected={active} aria-disabled={disabled || undefined} data-highlighted={active ? "" : undefined} data-disabled={disabled ? "" : undefined} onMouseDown={(event) => { event.preventDefault(); if (disabled) return; onMouseDown?.(event); }} className={cn(popupItemStructureClasses, className)} {...props} /> );}export function TriggerMenuIcon({ className, ...props }: TriggerMenuIconProps) { return ( <span data-control-ui="trigger-menu" data-control-family="popup" data-popup-kind="trigger-menu" data-slot="icon" className={cn("flex shrink-0 items-center justify-center [&_svg]:size-4", className)} {...props} /> );}export function TriggerMenuEmpty({ className, ...props }: TriggerMenuEmptyProps) { return ( <div data-control-ui="trigger-menu" data-control-family="popup" data-popup-kind="trigger-menu" data-slot="empty" className={cn("px-[calc(var(--padding-x)*0.5)]", className)} {...props} /> );}export function TriggerMenuGroup({ className, ...props }: TriggerMenuGroupProps) { return ( // biome-ignore lint/a11y/useSemanticElements: WAI-ARIA listbox option groups are role="group"; a fieldset is a form grouping. <div data-control-ui="trigger-menu" data-control-family="popup" data-popup-kind="trigger-menu" data-slot="group" role="group" className={cn("flex flex-col", className)} {...props} /> );}export function TriggerMenuGroupLabel({ className, ...props }: TriggerMenuGroupLabelProps) { return ( <div data-control-ui="trigger-menu" data-popup-kind="trigger-menu" data-slot="group-label" data-control-family="popup" data-popup-part="label" className={cn("px-[calc(var(--padding-x)*0.5)]", className)} {...props} /> );}Knobs
Typed custom properties the recipe paints with. Set one on the root — style, a utility class, or a skin — and every slot inherits it.
--cui-popup-* · 44 knobsHow the cascade resolves--cui-popup-title-foreground<color>var(--cui-popup-foreground)--cui-popup-title-font-size<length-percentage>var(--text-heading-3)--cui-popup-title-font-weight<number>var(--font-weight-semibold)--cui-popup-title-line-height<number>var(--text-heading-3--line-height)--cui-popup-title-letter-spacing<length>-0.025em--cui-popup-description-foreground<color>var(--muted-foreground)--cui-popup-close-inset<length-percentage>calc(var(--spacing) * 3)--cui-popup-item-inset<length-percentage>calc(var(--spacing) * 8)--cui-popup-label-padding-block<length-percentage>var(--cui-popup-item-padding-block)--cui-popup-label-padding-inline<length-percentage>var(--cui-popup-item-padding-inline)--cui-popup-label-font-size<length-percentage>var(--text-micro)--cui-popup-label-font-weight<number>var(--font-weight-medium)--cui-popup-label-letter-spacing<length>0.08em--cui-popup-label-text-transformnone | uppercase | lowercase | capitalizeuppercase--cui-popup-separator-inset<length-percentage>calc(var(--popover-padding) * -1)--cui-popup-shortcut-font-size<length-percentage>var(--text-body)--cui-popup-shortcut-letter-spacing<length>0.02em--cui-popup-status-success-foreground<color>var(--success-text)--cui-popup-status-warning-foreground<color>var(--warning-text)--cui-popup-status-info-foreground<color>var(--info-text)--cui-popup-status-error-foreground<color>var(--destructive)--cui-popup-radius<length-percentage>var(--radius-popover)--cui-popup-background<color>var(--popover)--cui-popup-foreground<color>var(--popover-foreground)--cui-popup-border-color<color>var(--control-rim)--cui-popup-border-width<length>var(--control-rim-width)--cui-popup-shadow*var(--shadow-pop)--cui-popup-backdrop-filter*blur(var(--backdrop-blur-popover))--cui-popup-backdrop-background<color>oklch(from var(--foreground) l c h / var(--overlay-opacity))--cui-popup-backdrop-blur<length>var(--backdrop-blur-overlay)--cui-popup-item-radius<length-percentage>var(--radius-popup-item-fit)--cui-popup-item-font-size<length-percentage>var(--text-body)--cui-popup-item-icon-size<length-percentage>1rem--cui-popup-item-padding-block<length-percentage>calc(var(--spacing) * 1)--cui-popup-item-padding-inline<length-percentage>calc(var(--padding-x) * 0.5)--cui-popup-item-min-height<length-percentage>max(var(--target-min), var(--control-h-xs))--cui-popup-item-highlight-shadow*inset 0 0 0 1px oklch(from var(--foreground) l c h / 0.35)--cui-popup-item-foreground<color>var(--foreground)--cui-popup-item-highlight-background<color>var(--hover-fill)--cui-popup-item-highlight-foreground<color>var(--cui-popup-item-foreground)--cui-popup-item-highlight-muted-foreground<color>var(--muted-foreground)--cui-popup-item-disabled-opacity<number>var(--disabled-opacity)--cui-popup-separator-color<color>var(--border)--cui-popup-shortcut-foreground<color>var(--muted-foreground)