A Radix select with a sized trigger, popper-positioned menu, scroll buttons, groups, labels, separators and a check indicator on the chosen item.
import { Label } from "@/components/ballmac/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ballmac/select"
export default function SelectDemo() {
return (
<div className="grid w-full max-w-xs gap-2">
<Label htmlFor="role">Role</Label>
<Select defaultValue="editor">
<SelectTrigger id="role" className="w-full">
<SelectValue placeholder="Choose a role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="owner">Owner</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="editor">Editor</SelectItem>
<SelectItem value="viewer">Viewer</SelectItem>
<SelectItem value="billing" disabled>
Billing (upgrade to add)
</SelectItem>
</SelectContent>
</Select>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/selectInstall the dependencies.
$ pnpm add radix-ui lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/directionCopy the source into your project.
components/ballmac/select.tsx// Ballmac UI: Select. https://ui.ballmac.com/components/select // Based on shadcn/ui's Select (MIT, Copyright (c) 2023 shadcn), restyled with trigger sizes and tw-animate-css transitions. "use client" import * as React from "react" import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" import { Select as SelectPrimitive } from "radix-ui" import { useDirection } from "@/lib/ballmac/direction" import { cn } from "@/lib/utils" function Select(props: React.ComponentProps<typeof SelectPrimitive.Root>) { const dir = useDirection(props.dir) return <SelectPrimitive.Root data-slot="select" dir={dir} {...props} /> } function SelectGroup(props: React.ComponentProps<typeof SelectPrimitive.Group>) { return <SelectPrimitive.Group data-slot="select-group" {...props} /> } function SelectValue(props: React.ComponentProps<typeof SelectPrimitive.Value>) { return <SelectPrimitive.Value data-slot="select-value" {...props} /> } type SelectTriggerProps = React.ComponentProps<typeof SelectPrimitive.Trigger> & { /** Matches Input and Button heights: sm is h-8, default is h-9. */ size?: "sm" | "default" } function SelectTrigger({ className, size = "default", children, ...props }: SelectTriggerProps) { return ( <SelectPrimitive.Trigger data-slot="select-trigger" data-size={size} className={cn( "flex w-fit min-w-0 items-center justify-between gap-2 whitespace-nowrap rounded-md border border-input bg-background px-3 text-sm text-foreground shadow-xs outline-none transition-[color,border-color,box-shadow] duration-150 dark:bg-input/30 dark:hover:bg-input/50", "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20", "disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground", "data-[size=default]:h-9 data-[size=sm]:h-8 data-[size=sm]:px-2.5 data-[size=sm]:text-[13px]", "*:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2", "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", className )} {...props} > {children} <SelectPrimitive.Icon className="flex"> <ChevronDownIcon className="size-4 opacity-60" aria-hidden="true" /> </SelectPrimitive.Icon> </SelectPrimitive.Trigger> ) } type SelectContentProps = React.ComponentProps<typeof SelectPrimitive.Content> & { /** "popper" opens below the trigger like a menu; "item-aligned" overlaps the trigger like a native select. */ position?: "popper" | "item-aligned" /** Distance in px from the trigger when position is "popper". */ sideOffset?: number } function SelectContent({ className, children, position = "popper", align = "start", sideOffset = 4, ...props }: SelectContentProps) { return ( <SelectPrimitive.Portal> <SelectPrimitive.Content data-slot="select-content" position={position} align={align} sideOffset={position === "popper" ? sideOffset : undefined} className={cn( "relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border bg-popover text-popover-foreground shadow-[0_8px_24px_-6px_rgb(0_0_0/0.18),0_2px_6px_-2px_rgb(0_0_0/0.08)]", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 motion-reduce:animate-none", "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", position === "popper" && "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )} {...props} > <SelectScrollUpButton /> <SelectPrimitive.Viewport className={cn( "p-1", position === "popper" && "h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1" )} > {children} </SelectPrimitive.Viewport> <SelectScrollDownButton /> </SelectPrimitive.Content> </SelectPrimitive.Portal> ) } function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) { return ( <SelectPrimitive.Label data-slot="select-label" className={cn("px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground", className)} {...props} /> ) } function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) { return ( <SelectPrimitive.Item data-slot="select-item" className={cn( "relative flex w-full cursor-default select-none items-center gap-2 rounded-md py-1.5 pe-8 ps-2 text-sm outline-hidden", "focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", "*:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", className )} {...props} > <span data-slot="select-item-indicator" className="absolute end-2 flex size-3.5 items-center justify-center"> <SelectPrimitive.ItemIndicator> <CheckIcon className="size-4 text-foreground" aria-hidden="true" /> </SelectPrimitive.ItemIndicator> </span> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> </SelectPrimitive.Item> ) } function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) { return ( <SelectPrimitive.Separator data-slot="select-separator" className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)} {...props} /> ) } function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) { return ( <SelectPrimitive.ScrollUpButton data-slot="select-scroll-up-button" className={cn("flex cursor-default items-center justify-center py-1 text-muted-foreground", className)} {...props} > <ChevronUpIcon className="size-4" aria-hidden="true" /> </SelectPrimitive.ScrollUpButton> ) } function SelectScrollDownButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) { return ( <SelectPrimitive.ScrollDownButton data-slot="select-scroll-down-button" className={cn("flex cursor-default items-center justify-center py-1 text-muted-foreground", className)} {...props} > <ChevronDownIcon className="size-4" aria-hidden="true" /> </SelectPrimitive.ScrollDownButton> ) } export { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue, type SelectContentProps, type SelectTriggerProps, }Update the import paths to match your project setup.
Usage
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue } from "@/components/ballmac/select"The full example is in the Code tab above.
Examples
Grouped
import { Globe } from "lucide-react"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ballmac/select"
const regions = [
{ label: "North America", items: [["us-east", "US East (Virginia)"], ["us-west", "US West (Oregon)"], ["ca-central", "Canada (Montreal)"]] },
{ label: "Europe", items: [["eu-west", "Ireland"], ["eu-central", "Frankfurt"], ["eu-north", "Stockholm"]] },
{ label: "Asia Pacific", items: [["ap-south", "Mumbai"], ["ap-southeast", "Singapore"], ["ap-northeast", "Tokyo"]] },
] as const
export default function SelectGrouped() {
return (
<Select>
<SelectTrigger className="w-full max-w-64" aria-label="Deployment region">
<Globe />
<SelectValue placeholder="Deployment region" />
</SelectTrigger>
<SelectContent>
{regions.map((region, i) => (
<SelectGroup key={region.label}>
{i > 0 ? <SelectSeparator /> : null}
<SelectLabel>{region.label}</SelectLabel>
{region.items.map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
)
}API reference
<SelectTrigger>
| Prop | Type | Default |
|---|---|---|
sizeMatches Input and Button heights: sm is h-8, default is h-9. | "sm" | "default" | "default" |
<SelectContent>
| Prop | Type | Default |
|---|---|---|
position"popper" opens below the trigger like a menu; "item-aligned" overlaps the trigger like a native select. | "popper" | "item-aligned" | "popper" |
sideOffsetDistance in px from the trigger when position is "popper". | number | 4 |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Space / Enter / ArrowDown | Opens the menu |
| ArrowUp / ArrowDown | Moves between items |
| Enter | Selects the focused item |
| Type a letter | Jumps to the matching item |
| Escape | Closes the menu |
Use with AI
Pick one value from a list. Compose Select > SelectTrigger > SelectValue and SelectContent > SelectItem (group with SelectGroup + SelectLabel). The menu is portalled and keyboard navigable with typeahead. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Select (@ballmac/select) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Choosing one option from 4 to 20 fixed values (timezone, role, region)
- Compact filters in toolbars (size sm)
- Grouped options such as regions by continent
Not for
- Two or three options that should stay visible (use a radio group or segmented control)
- Long searchable lists (use a combobox)
- Actions rather than values (use a dropdown menu)
Registry JSON: https://ui.ballmac.com/r/select.json
Credits
Based on shadcn/ui Select, adapted by Ballmac. Free to use in personal and commercial projects.
- Registry
- @ballmac/directionshadcn/utils
Pairs well with
Label
An accessible form label built on Radix Label that dims itself when the paired peer control is disabled and never selects text on double-click.
Button
A button with six variants, three sizes, a pill shape and a built-in loading state. buttonVariants() styles links the same way.
Dialog
A Radix modal dialog with a blurred overlay, centered card surface that fits 360 px screens, header and footer parts, optional close button and animations.
Accordion
Vertically stacked disclosure sections on Radix Accordion, single or multiple open, with hairline dividers, a rotating plus and height animation.