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

PropTypeDefaultDescription
itemsComboboxItem[]-List of options displayed in the combobox.
valuestring-Controlled value of the selected item.
defaultValuestring-Initial selected value for uncontrolled usage.
onValueChange(value: string) => void-Callback triggered when the selected value changes.
placeholderstring"Select..."Text displayed when no item is selected.
searchablebooleanfalseEnables a search input for filtering items.
disabledbooleanfalseDisables interaction with the combobox.
widthnumber | string"100%"Sets the width of the combobox trigger.
maxDropdownHeightnumber240Maximum height of the dropdown menu in pixels.
variantComboboxVariant"paper"Controls the visual color variant of the combobox.
shapeComboboxShape"rounded"Controls the shape of the combobox trigger and dropdown.
borderColorstring"#111"Sets the border color of the combobox.
rotatenumber0Controls the rotation angle of the combobox for a hand-drawn effect.
roughOptionsRoughComboboxOptions-Custom RoughJS rendering configuration.
classNamestring-Additional classes applied to the outer combobox wrapper.
triggerClassNamestring-Additional classes applied to the combobox trigger.
dropdownClassNamestring-Additional classes applied to the dropdown menu.
itemClassNamestring-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