Components
Combobox
A hand-drawn combobox component.
Code
tsx
806 lines
"use client" import { useEffect, useId, useMemo, useRef, useState, } from "react" import rough from "roughjs" import { cn } from "@/lib/utils" type ComboboxShape = "rectangle" | "rounded" type ComboboxVariant = "paper" | "yellow" | "purple" | "green" | "pink" | "blue" | "gray" | "silver" | "red" | "orange" | "sky" type ComboboxItem = { value: string label: string disabled?: boolean icon?: React.ReactNode } type RoughComboboxOptions = { seed?: number stroke?: string strokeWidth?: number fill?: string fillStyle?: "solid" | "hachure" | "zigzag" | "cross-hatch" | "dots" | "dashed" | "zigzag-line" hachureGap?: number hachureAngle?: number roughness?: number bowing?: number } type ComboboxProps = { items: ComboboxItem[] value?: string defaultValue?: string onValueChange?: (value: string) => void placeholder?: string searchable?: boolean disabled?: boolean width?: number | string maxDropdownHeight?: number variant?: ComboboxVariant shape?: ComboboxShape borderColor?: string rotate?: number roughOptions?: RoughComboboxOptions className?: string triggerClassName?: string dropdownClassName?: string itemClassName?: string } const colors: Record<ComboboxVariant, string> = { yellow: "#fde047", purple: "#d8c7ff", green: "#bbf7d0", pink: "#fbcfe8", blue: "#bfdbfe", gray: "#e5e7eb", silver: "#d1d5db", red: "#fecaca", orange: "#fed7aa", sky: "#bae6fd", paper: "#fff7df", } const seedMap: Record<ComboboxVariant, number> = { yellow: 20, purple: 40, green: 60, pink: 80, blue: 100, gray: 120, silver: 140, red: 160, orange: 180, sky: 200, paper: 220, } function roundedRectPath( x: number, y: number, width: number, height: number, radius: number ) { const r = Math.min(radius, width / 2, height / 2) return ` M ${x + r} ${y} L ${x + width - r} ${y} Q ${x + width} ${y} ${x + width} ${y + r} L ${x + width} ${y + height - r} Q ${x + width} ${y + height} ${x + width - r} ${y + height} L ${x + r} ${y + height} Q ${x} ${y + height} ${x} ${y + height - r} L ${x} ${y + r} Q ${x} ${y} ${x + r} ${y} Z ` } export function Combobox({ items, value, defaultValue, onValueChange, placeholder = "Select...", searchable = false, disabled = false, width = 260, maxDropdownHeight = 260, variant = "paper", shape = "rounded", borderColor = "#111", rotate = 0, roughOptions, className, triggerClassName, dropdownClassName, itemClassName, }: ComboboxProps) { const id = useId() const wrapperRef = useRef<HTMLDivElement>(null) const triggerRef = useRef<HTMLButtonElement>(null) const triggerSvgRef = useRef<SVGSVGElement>(null) const dropdownSvgRef = useRef<SVGSVGElement>(null) const listRef = useRef<HTMLDivElement>(null) const inputRef = useRef<HTMLInputElement>(null) const itemRefs = useRef<(HTMLButtonElement | null)[]>([]) const isControlled = value !== undefined const [internalValue, setInternalValue] = useState(defaultValue ?? "") const selectedValue = isControlled ? value : internalValue const [open, setOpen] = useState(false) const [search, setSearch] = useState("") const [highlightedIndex, setHighlightedIndex] = useState(-1) const [triggerWidth, setTriggerWidth] = useState(260) const [dropdownHeight, setDropdownHeight] = useState(0) const [isAnimating, setIsAnimating] = useState(false) const selectedItem = useMemo( () => items.find( (item) => item.value === selectedValue ), [items, selectedValue] ) const filteredItems = useMemo(() => { if (!searchable) return items const query = search.trim() if (!query) return items return items.filter((item) => item.label .toLowerCase() .includes( query.toLowerCase() ) ) }, [ items, search, searchable, ]) const commonOptions = { seed: roughOptions?.seed ?? seedMap[variant], stroke: roughOptions?.stroke ?? borderColor, strokeWidth: roughOptions?.strokeWidth ?? 1.8, fill: roughOptions?.fill ?? colors[variant], fillStyle: roughOptions?.fillStyle ?? "hachure", hachureGap: roughOptions?.hachureGap ?? 7, hachureAngle: roughOptions?.hachureAngle ?? -10, roughness: roughOptions?.roughness ?? 1.5, bowing: roughOptions?.bowing ?? 0.9, } function select(value: string) { if (!isControlled) { setInternalValue(value) } onValueChange?.(value) setOpen(false) setSearch("") setHighlightedIndex(-1) } useEffect(() => { const trigger = triggerRef.current if (!trigger) return function updateWidth() { //@ts-ignore setTriggerWidth(trigger.offsetWidth) } updateWidth() const observer = new ResizeObserver(updateWidth) observer.observe(trigger) window.addEventListener("resize", updateWidth) return () => { observer.disconnect() window.removeEventListener("resize", updateWidth) } }, []) useEffect(() => { const itemHeight = 42 const height = Math.min( filteredItems.length * itemHeight + (searchable ? 54 : 0) + 8, maxDropdownHeight ) setDropdownHeight(height) }, [ filteredItems, searchable, maxDropdownHeight, ]) useEffect(() => { const svg = triggerSvgRef.current if (!svg) return svg.replaceChildren() const rc = rough.svg(svg) const width = triggerWidth const height = 48 const trigger = shape === "rounded" ? rc.path( roundedRectPath( 2, 2, width - 4, height - 4, 14 ), commonOptions ) : rc.rectangle( 2, 2, width - 4, height - 4, commonOptions ) svg.appendChild(trigger) const chevron = rc.path( open ? ` M ${width - 28} 28 L ${width - 20} 20 L ${width - 12} 28 ` : ` M ${width - 28} 20 L ${width - 20} 28 L ${width - 12} 20 `, { seed: (roughOptions?.seed ?? seedMap[variant]) + 50, stroke: roughOptions?.stroke ?? borderColor, strokeWidth: 2, roughness: roughOptions?.roughness ?? 1.3, bowing: roughOptions?.bowing ?? 0.8, } ) svg.appendChild(chevron) }, [ triggerWidth, open, shape, variant, borderColor, roughOptions, ]) useEffect(() => { if (!open) return const svg = dropdownSvgRef.current if (!svg) return svg.replaceChildren() svg.setAttribute( "viewBox", `0 0 ${triggerWidth} ${dropdownHeight}` ) const rc = rough.svg(svg) const dropdown = shape === "rounded" ? rc.path( roundedRectPath( 2, 2, triggerWidth - 4, dropdownHeight - 4, 14 ), commonOptions ) : rc.rectangle( 2, 2, triggerWidth - 4, dropdownHeight - 4, commonOptions ) svg.appendChild(dropdown) }, [ open, triggerWidth, dropdownHeight, shape, variant, borderColor, roughOptions, ]) useEffect(() => { if (open) { setIsAnimating(true) const id = setTimeout(() => { setIsAnimating(false) }, 180) return () => clearTimeout(id) } }, [open]) useEffect(() => { function handleOutsideClick(e: MouseEvent) { if ( wrapperRef.current && !wrapperRef.current.contains(e.target as Node) ) { setOpen(false) setSearch("") setHighlightedIndex(-1) } } document.addEventListener( "mousedown", handleOutsideClick ) return () => { document.removeEventListener( "mousedown", handleOutsideClick ) } }, []) useEffect(() => { if (!open || !searchable) return requestAnimationFrame(() => { inputRef.current?.focus() }) }, [open, searchable]) useEffect(() => { if (!open) return const firstEnabled = filteredItems.findIndex( (item) => !item.disabled ) setHighlightedIndex(firstEnabled) }, [open, filteredItems]) useEffect(() => { if ( highlightedIndex < 0 || !itemRefs.current[highlightedIndex] ) return itemRefs.current[ highlightedIndex ]?.scrollIntoView({ block: "nearest", }) }, [highlightedIndex]) function handleKeyDown( e: | React.KeyboardEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLInputElement> ) { if (disabled) return switch (e.key) { case "ArrowDown": { e.preventDefault() if (!open) { setOpen(true) return } let next = highlightedIndex do { next++ } while ( next < filteredItems.length && filteredItems[next]?.disabled ) if (next < filteredItems.length) { setHighlightedIndex(next) } break } case "ArrowUp": { e.preventDefault() let prev = highlightedIndex do { prev-- } while ( prev >= 0 && filteredItems[prev]?.disabled ) if (prev >= 0) { setHighlightedIndex(prev) } break } case "Enter": { e.preventDefault() if (!open) { setOpen(true) return } const item = filteredItems[ highlightedIndex ] if ( item && !item.disabled ) { select(item.value) } break } case "Escape": { e.preventDefault() setOpen(false) setSearch("") setHighlightedIndex(-1) triggerRef.current?.focus() break } case "Tab": { setOpen(false) break } } } function handleSearch( value: string ) { setSearch(value) setHighlightedIndex(0) } function toggleOpen() { if (disabled) return setOpen((prev) => !prev) } return ( <div ref={wrapperRef} className={cn("relative inline-block", className)} style={{ width, transform: rotate ? `rotate(${rotate}deg)` : undefined, }} > <button ref={triggerRef} id={id} type="button" role="combobox" aria-expanded={open} aria-haspopup="listbox" aria-controls={`${id}-listbox`} disabled={disabled} onClick={toggleOpen} onKeyDown={handleKeyDown} className={cn( "relative h-12 w-full text-left outline-none font-family-hand", "focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2", disabled && "cursor-not-allowed opacity-60", triggerClassName )} > <svg ref={triggerSvgRef} className="absolute inset-0 h-full w-full" viewBox={`0 0 ${triggerWidth} 48`} aria-hidden="true" /> <div className="relative z-10 flex h-full items-center justify-between px-4"> <div className="flex items-center gap-2 truncate"> {selectedItem?.icon} <span className={cn( "truncate", selectedItem ? "text-black" : "text-muted-foreground" )} > {selectedItem?.label ?? placeholder} </span> </div> </div> </button> {open && ( <div className={cn( "absolute left-0 top-[calc(100%+8px)] z-50 w-full origin-top font-family-gaegu", isAnimating && "animate-in fade-in zoom-in-95 duration-150" )} > <div className="relative"> <svg ref={dropdownSvgRef} className="absolute inset-0 h-full w-full" style={{ height: dropdownHeight, }} aria-hidden="true" /> <div ref={listRef} id={`${id}-listbox`} role="listbox" className={cn( "relative z-10 overflow-y-auto py-2 px-2", dropdownClassName )} style={{ maxHeight: maxDropdownHeight, }} > {searchable && ( <div className="px-3 pb-2"> <input ref={inputRef} value={search} onChange={(e) => handleSearch( e.target.value ) } onKeyDown={ handleKeyDown } placeholder="Search..." className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 text-sm outline-none" /> </div> )} {filteredItems.length === 0 ? ( <div className="px-4 py-3 text-sm text-muted-foreground"> No results found. </div> ) : ( filteredItems.map( ( item, index ) => ( <button key={ item.value } ref={(el) => { itemRefs.current[ index ] = el }} type="button" role="option" aria-selected={ selectedValue === item.value } disabled={ item.disabled } onMouseEnter={() => setHighlightedIndex( index ) } onClick={() => !item.disabled && select( item.value ) } className={cn( "flex w-full items-center justify-between px-4 py-2 rounded-lg text-left transition-colors", highlightedIndex === index && "bg-black/5", item.disabled && "cursor-not-allowed opacity-50", itemClassName )} > <div className="flex items-center gap-2"> {item.icon} <span> { item.label } </span> </div> {selectedValue === item.value && ( <span className="font-semibold"> ✓ </span> )} </button> ) ) )} </div> </div> </div> )} </div> ) }
Usage
tsx
const frameworks = [ { label: "React", value: "react", }, { label: "Next.js", value: "next", } ] return ( <div className="flex flex-col" > <div className="flex flex-wrap items-center gap-6"> <Combobox items={frameworks} placeholder="Select Framework" variant="green" roughOptions={{ roughness: 1.25, fillStyle: "zigzag", hachureGap: 3.5 }} /> </div> </div> )
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | ComboboxItem[] | - | List of options displayed in the combobox. |
value | string | - | Controlled value of the selected item. |
defaultValue | string | - | Initial selected value for uncontrolled usage. |
onValueChange | (value: string) => void | - | Callback triggered when the selected value changes. |
placeholder | string | "Select..." | Text displayed when no item is selected. |
searchable | boolean | false | Enables a search input for filtering items. |
disabled | boolean | false | Disables interaction with the combobox. |
width | number | string | "100%" | Sets the width of the combobox trigger. |
maxDropdownHeight | number | 240 | Maximum height of the dropdown menu in pixels. |
variant | ComboboxVariant | "paper" | Controls the visual color variant of the combobox. |
shape | ComboboxShape | "rounded" | Controls the shape of the combobox trigger and dropdown. |
borderColor | string | "#111" | Sets the border color of the combobox. |
rotate | number | 0 | Controls the rotation angle of the combobox for a hand-drawn effect. |
roughOptions | RoughComboboxOptions | - | Custom RoughJS rendering configuration. |
className | string | - | Additional classes applied to the outer combobox wrapper. |
triggerClassName | string | - | Additional classes applied to the combobox trigger. |
dropdownClassName | string | - | Additional classes applied to the dropdown menu. |
itemClassName | string | - | Additional classes applied to each dropdown item. |
Rough Options
The roughOptions prop allows you to customize the hand-drawn appearance of the combobox.
seed(number): Controls the randomness of the RoughJS stroke.stroke(string): Sets the border stroke color.strokeWidth(number): Sets the width of the border stroke.fill(string): Sets the fill color.fillStyle: Controls the filling style. ["solid","hachure","zigzag","cross-hatch","dots","dashed","zigzag-line"]hachureGap(number): Controls the gap between hachure strokes.hachureAngle(number): Controls the angle of hachure strokes.roughness(number): Controls how irregular the hand-drawn strokes appear.bowing(number): Controls the bending/curving effect of the strokes.
Variants
- yellow
- purple
- green
- pink
- blue
- gray
- silver
- red
- orange
- sky
- paper
Shapes
- rectangle
- rounded