Select
A drawn box with a chevron that opens a slip of choices, the chosen one ticked.
Built on Base UI Selectimport { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from "@/components/ui/select";
const pens = [
{ label: "Blue ballpoint", value: "blue" },
{ label: "Black fineliner", value: "black" },
{ label: "Green ink", value: "green" },
];
const pencils = [
{ label: "HB pencil", value: "hb" },
{ label: "2B pencil", value: "2b" },
];
export default function SelectDemo() {
return (
<div className="flex flex-wrap items-center gap-5">
<Select items={[...pens, ...pencils]} defaultValue="blue">
<SelectTrigger aria-label="Pen" className="w-52" seed="select">
<SelectValue />
</SelectTrigger>
<SelectContent seed="select-list">
<SelectGroup>
<SelectLabel>Pens</SelectLabel>
{pens.map((pen) => (
<SelectItem key={pen.value} value={pen.value}>
{pen.label}
</SelectItem>
))}
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Pencils</SelectLabel>
{pencils.map((pen) => (
<SelectItem key={pen.value} value={pen.value}>
{pen.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Select items={pens}>
<SelectTrigger aria-label="Pen, small" size="sm" className="w-44" seed="select-sm">
<SelectValue placeholder="Pick a pen" />
</SelectTrigger>
<SelectContent seed="select-sm-list">
{pens.map((pen) => (
<SelectItem key={pen.value} value={pen.value}>
{pen.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}Installation
pnpm dlx shadcn@latest add @ballpoint/ selectOr copy the source
"use client";
import { Select as SelectPrimitive } from "@base-ui/react/select";
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 { InkOutline } from "@/lib/ink-outline";
import { InkPanel } from "@/lib/ink-panel";
const Select = SelectPrimitive.Root;
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return <SelectPrimitive.Group data-slot="select-group" className={cn("scroll-my-1", className)} {...props} />;
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return <SelectPrimitive.Value data-slot="select-value" className={cn("flex flex-1 truncate text-left", className)} {...props} />;
}
/**
* The closed select: a drawn box like an Input's, with a chevron. Focus
* draws one more pass in full ink; aria-invalid redraws it in red pen.
*/
function SelectTrigger({
className,
size = "default",
children,
seed,
roughness,
passes,
radius,
corners,
draw,
weight,
speed,
...props
}: SelectPrimitive.Trigger.Props & Omit<Pen, "fill" | "shadow"> & { size?: "sm" | "default"; seed?: string | number }) {
const pen = usePen({ roughness, passes, radius, corners, draw, weight, speed });
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"group/select-trigger ink-within relative inline-flex w-fit min-w-40 cursor-pointer items-center justify-between gap-2 px-3 text-base whitespace-nowrap text-foreground outline-none select-none",
"data-[size=default]:h-10 data-[size=sm]:h-8 data-[size=sm]:px-2.5 data-[size=sm]:text-sm data-placeholder:text-ink-3",
"disabled:cursor-not-allowed disabled:opacity-50 data-disabled:cursor-not-allowed data-disabled:opacity-50",
"*:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2",
"[&_svg:not(.ink-sketch)]:pointer-events-none [&_svg:not(.ink-sketch)]:shrink-0 [&_svg:not(.ink-sketch):not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<InkOutline
pen={pen}
seed={seed}
estimate={[160, 40]}
focusPass
className={cn(
"text-ink-3 transition-colors duration-(--dur-hover)",
"group-hover/select-trigger:text-ink group-data-popup-open/select-trigger:text-ink group-focus-visible/select-trigger:text-ink",
"group-aria-invalid/select-trigger:text-destructive group-data-invalid/select-trigger:text-destructive",
)}
/>
{children}
<SelectPrimitive.Icon className="flex text-ink-3 transition-transform duration-(--dur-state) ease-out group-data-popup-open/select-trigger:rotate-180 motion-reduce:transition-none">
<InkGlyph name="chevron-down" className="size-3.5" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
/**
* The list on a slip of paper, pencilled round and lifted off its shadow.
* By default it opens over the trigger with the chosen item lined up on it,
* the way a native select does.
*/
function SelectContent({
className,
style,
children,
side = "bottom",
sideOffset = 6,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
seed,
roughness,
passes,
radius,
corners,
shadow,
draw,
weight,
speed,
...props
}: SelectPrimitive.Popup.Props &
Pick<SelectPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"> &
Omit<Pen, "fill"> & { seed?: string | number }) {
const pen = usePen({ roughness, passes, radius, corners, shadow, draw, weight, speed });
const marks = { ...inkWash, ...inkRules };
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50 outline-none select-none"
>
<SelectPrimitive.Popup
data-slot="select-content"
className={cn(
"ink-paper relative isolate min-w-(--anchor-width) origin-(--transform-origin) text-popover-foreground outline-none data-[side=none]:min-w-[calc(var(--anchor-width)+1rem)]",
"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] data-[side=none]:data-starting-style:scale-100",
className,
)}
style={typeof style === "function" ? (state) => ({ ...marks, ...style(state) }) : { ...marks, ...style }}
{...props}
>
<InkPanel pen={pen} seed={seed} estimate={[180, 200]} offset={5} maxRadius={12} className="text-ink-3" />
<SelectScrollUpButton />
<SelectPrimitive.List data-slot="select-list" className="relative max-h-(--available-height) scroll-py-7 overflow-y-auto overscroll-contain p-1.5">
{children}
</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
);
}
function SelectLabel({ className, ...props }: SelectPrimitive.GroupLabel.Props) {
return <SelectPrimitive.GroupLabel data-slot="select-label" className={cn("px-2.5 pt-1.5 pb-1 text-sm text-ink-3", className)} {...props} />;
}
/** The highlighted item is shaded in with a quick pass of the pen; the chosen one is ticked. */
function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative isolate flex w-full cursor-default items-center gap-2 py-1 pr-9 pl-2.5 text-base outline-none select-none",
"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-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",
className,
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 items-center gap-2 whitespace-nowrap">{children}</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator data-slot="select-item-indicator" className="pointer-events-none absolute right-2.5 flex size-4 items-center justify-center">
<InkGlyph name="check" draw="mount" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
);
}
/** A short rule drawn across the slip (see inkRules). */
function SelectSeparator({ className, ...props }: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none 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 SelectScrollUpButton({ className, ...props }: SelectPrimitive.ScrollUpArrow.Props) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn("ink-paper absolute inset-x-1 top-1 z-10 flex h-6 cursor-default items-center justify-center text-ink-3", className)}
{...props}
>
<InkGlyph name="chevron-up" className="size-3.5" />
</SelectPrimitive.ScrollUpArrow>
);
}
function SelectScrollDownButton({ className, ...props }: SelectPrimitive.ScrollDownArrow.Props) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn("ink-paper absolute inset-x-1 bottom-1 z-10 flex h-6 cursor-default items-center justify-center text-ink-3", className)}
{...props}
>
<InkGlyph name="chevron-down" className="size-3.5" />
</SelectPrimitive.ScrollDownArrow>
);
}
export { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue };Usage
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
const pens = [
{ label: "Blue ballpoint", value: "blue" },
{ label: "Black fineliner", value: "black" },
]
<Select items={pens}>
<SelectTrigger aria-label="Pen" className="w-52">
<SelectValue placeholder="Pick a pen" />
</SelectTrigger>
<SelectContent>
{pens.map((pen) => (
<SelectItem key={pen.value} value={pen.value}>
{pen.label}
</SelectItem>
))}
</SelectContent>
</Select>API reference
- Select items
{ label, value }[] - Lets SelectValue show the chosen item's label rather than its value.
- SelectTrigger size
"default" | "sm"default"default" - Height and type size, matching Input.
- SelectContent alignItemWithTrigger
booleandefaulttrue - Opens over the trigger with the chosen item lined up on it, like a native select. False drops it below.
- seed
string | number - On SelectTrigger and SelectContent: pins the drawing.
Every other prop goes to Base UI's Select.
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.