Dropdown Menu
A slip of choices: the highlighted one shaded in with the pen, ticks and dots drawn as you check them.
Built on Base UI Menu"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export default function DropdownMenuDemo() {
const [margins, setMargins] = useState(true);
const [ruling, setRuling] = useState("lined");
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" seed="menu-open" />}>Notebook</DropdownMenuTrigger>
<DropdownMenuContent seed="menu" className="w-60">
<DropdownMenuGroup>
<DropdownMenuLabel>Notebook</DropdownMenuLabel>
<DropdownMenuItem>
New page
<DropdownMenuShortcut>⌘N</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
Duplicate
<DropdownMenuShortcut>⌘D</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Share</DropdownMenuSubTrigger>
<DropdownMenuSubContent seed="menu-share">
<DropdownMenuItem>By email</DropdownMenuItem>
<DropdownMenuItem>Copy link</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem checked={margins} onCheckedChange={setMargins}>
Show margins
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>Ruling</DropdownMenuLabel>
<DropdownMenuRadioGroup value={ruling} onValueChange={setRuling}>
<DropdownMenuRadioItem value="lined">Lined</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="squared">Squared</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="plain">Plain</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
Tear out page
<DropdownMenuShortcut>⌫</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}Installation
pnpm dlx shadcn@latest add @ballpoint/ dropdown-menuOr copy the source
"use client";
import type { ComponentProps } from "react";
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
import { cn } from "@/lib/utils";
import { usePen, type Pen } from "@/hooks/use-ink-box";
import { inkRules, inkWash } from "@/lib/ink";
import { InkGlyph } from "@/lib/ink-glyphs";
import { InkPanel } from "@/lib/ink-panel";
// Every row: the highlighted one is shaded in with a quick pass of the pen,
// a patch of shading (inkWash) masking a pseudo-element in the row's own
// colour, so a destructive row is shaded in red.
const itemClass = cn(
"relative isolate flex cursor-default items-center gap-2 px-2.5 py-1 text-base outline-none select-none data-inset:pl-9",
"before:pointer-events-none before:absolute before:inset-x-0.5 before:inset-y-0 before:-z-10 before:bg-current before:opacity-0 before:transition-opacity before:duration-(--dur-press)",
"before:[mask-image:var(--ink-wash-1)] before:[mask-size:100%_100%] before:[mask-repeat:no-repeat] nth-[3n+2]:before:[mask-image:var(--ink-wash-2)] nth-[3n]:before:[mask-image:var(--ink-wash-3)]",
"data-highlighted:before:opacity-(--ink-wash-opacity) [--ink-wash-opacity:0.13]",
"data-disabled:pointer-events-none data-disabled:opacity-50",
"[&_svg:not(.ink-sketch)]:pointer-events-none [&_svg:not(.ink-sketch)]:shrink-0 [&_svg:not(.ink-sketch):not([class*='size-'])]:size-4",
);
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
/**
* A slip of paper of choices, pencilled round and lifted off its shadow.
* Long menus scroll inside the slip, so the drawing is never cut off.
*/
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 6,
className,
style,
children,
seed,
roughness,
passes,
radius,
corners,
shadow,
draw,
weight,
speed,
...props
}: MenuPrimitive.Popup.Props &
Pick<MenuPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset"> &
Omit<Pen, "fill"> & { seed?: string | number }) {
const pen = usePen({ roughness, passes, radius, corners, shadow, draw, weight, speed });
const marks = { ...inkWash, ...inkRules };
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner className="isolate z-50 outline-none" align={align} alignOffset={alignOffset} side={side} sideOffset={sideOffset}>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn(
"ink-paper relative isolate w-max min-w-[max(11rem,var(--anchor-width))] max-w-(--available-width) origin-(--transform-origin) text-popover-foreground outline-none",
"transition-[opacity,scale] duration-(--dur-hover) ease-out data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:ease-in",
"motion-safe:data-starting-style:scale-[0.97] motion-safe:data-ending-style:scale-[0.98]",
className,
)}
style={typeof style === "function" ? (state) => ({ ...marks, ...style(state) }) : { ...marks, ...style }}
{...props}
>
<InkPanel pen={pen} seed={seed} estimate={[200, 180]} offset={5} maxRadius={12} className="text-ink-3" />
<div data-slot="dropdown-menu-list" className="max-h-[calc(var(--available-height)-1rem)] overflow-y-auto overscroll-contain p-1.5">
{children}
</div>
</MenuPrimitive.Popup>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
);
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
function DropdownMenuLabel({ className, inset, ...props }: MenuPrimitive.GroupLabel.Props & { inset?: boolean }) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn("px-2.5 pt-1.5 pb-1 text-sm text-ink-3 data-inset:pl-9", className)}
{...props}
/>
);
}
function DropdownMenuItem({ className, inset, variant = "default", ...props }: MenuPrimitive.Item.Props & { inset?: boolean; variant?: "default" | "destructive" }) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(itemClass, "data-[variant=destructive]:text-destructive", className)}
{...props}
/>
);
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({ className, inset, children, ...props }: MenuPrimitive.SubmenuTrigger.Props & { inset?: boolean }) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(itemClass, "data-popup-open:before:opacity-(--ink-wash-opacity)", className)}
{...props}
>
{children}
<InkGlyph name="chevron-right" className="ml-auto size-3.5 text-ink-3 rtl:-scale-x-100" />
</MenuPrimitive.SubmenuTrigger>
);
}
function DropdownMenuSubContent({ align = "start", alignOffset = -6, side = "right", sideOffset = 2, className, ...props }: ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("min-w-36", className)}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
);
}
/** Checking it ticks it, the pen running up and out; unchecking pulls the tick back out. */
function DropdownMenuCheckboxItem({ className, children, inset, ...props }: MenuPrimitive.CheckboxItem.Props & { inset?: boolean }) {
return (
<MenuPrimitive.CheckboxItem data-slot="dropdown-menu-checkbox-item" data-inset={inset} className={cn(itemClass, "pr-9", className)} {...props}>
{children}
<MenuPrimitive.CheckboxItemIndicator
keepMounted
data-slot="dropdown-menu-checkbox-item-indicator"
className="pointer-events-none absolute right-2.5 flex size-4 items-center justify-center"
>
<InkGlyph name="check" draw="checked" />
</MenuPrimitive.CheckboxItemIndicator>
</MenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return <MenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
}
/** The chosen one gets a dot scribbled in beside it. */
function DropdownMenuRadioItem({ className, children, inset, ...props }: MenuPrimitive.RadioItem.Props & { inset?: boolean }) {
return (
<MenuPrimitive.RadioItem data-slot="dropdown-menu-radio-item" data-inset={inset} className={cn(itemClass, "pr-9", className)} {...props}>
{children}
<MenuPrimitive.RadioItemIndicator
keepMounted
data-slot="dropdown-menu-radio-item-indicator"
className="pointer-events-none absolute right-2.5 flex size-4 items-center justify-center"
>
<InkGlyph name="dot" draw="checked" />
</MenuPrimitive.RadioItemIndicator>
</MenuPrimitive.RadioItem>
);
}
/** A short rule drawn across the slip (see inkRules). */
function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("mx-1 my-1.5 h-[5px] bg-ink-4 [mask-image:var(--ink-rule-2)] [mask-size:100%_100%] [mask-repeat:no-repeat]", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({ className, ...props }: ComponentProps<"span">) {
return <span data-slot="dropdown-menu-shortcut" className={cn("ml-auto pl-4 text-sm tracking-wide text-ink-3", className)} {...props} />;
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};Usage
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" />}>Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Notebook</DropdownMenuLabel>
<DropdownMenuItem>New page</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">Tear out page</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>API reference
- DropdownMenuItem variant
"default" | "destructive"default"default" - Destructive rows are written, and shaded, in red pen.
- inset
boolean - On items, labels and sub-triggers: lines them up with checkable items.
- DropdownMenuCheckboxItem checked
boolean - Ticked when true; the tick draws in and pulls back out.
- DropdownMenuContent side / align
stringdefault"bottom" / "start" - Where it opens against the trigger.
- seed
string | number - On DropdownMenuContent: pins the drawing.
Every other prop goes to Base UI's Menu.
Pen settings
The drawing also takes draw, roughness, passes, radius, corners, shadow, weight and speed, as props or from the nearest InkProvider. What each one does.