콤보박스
입력하는 대로 선택 항목을 필터링하는 자동 완성 입력 필드(국가 선택기, 태그 입력 필드, 긴 드롭다운 목록 등)로, 네오브루탈리즘 스타일의 테두리와 그림자가 적용되어 있습니다.
"use client"
import {Combobox는 텍스트 입력과 필터링되는 팝업 목록을 짝지어 줍니다. 입력해서 범위를 좁히고, 항목을 강조하고, 값을 확정합니다. Base UI Combobox 프리미티브를 기반으로 하며(두 백엔드가 모두 이것을 사용합니다. Radix UI에는 combobox가 없습니다), 네오브루탈리즘 공식(두꺼운 테두리, 선명한 그림자, 굵은 서체)에 맞춰 스타일을 입혔습니다.
다음과 같은 경우에 사용합니다.
- 항목이 많은 select — 국가, 시간대, 통화처럼 평범한 select로는 스크롤하기 힘든 목록.
- 태그와 담당자 선택기 — 레이블, 팀원, 카테고리를 칩으로 다루는
multiple모드. - 레코드 검색 — 이름 일부만 입력해 고객, 저장소, 문서로 곧바로 이동합니다.
설치#
pnpm dlx shadcn@latest add https://neobrutalism.com/r/base/combobox.jsonnpx shadcn@latest add https://neobrutalism.com/r/base/combobox.jsonyarn dlx shadcn@latest add https://neobrutalism.com/r/base/combobox.jsonbunx --bun shadcn@latest add https://neobrutalism.com/r/base/combobox.json
다음 의존성을 설치합니다.
pnpm add @base-ui/reactnpm install @base-ui/reactyarn add @base-ui/reactbun add @base-ui/react
다음 코드를 복사해 프로젝트에 붙여넣습니다.
"use client"
import * as React from "react"
import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox"
import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group"
const Combobox = ComboboxPrimitive.Root
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
}
function ComboboxTrigger({
className,
children,
...props
}: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
{children}
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</ComboboxPrimitive.Trigger>
)
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
render={<InputGroupButton variant="ghost" size="icon-xs" />}
className={cn(className)}
{...props}
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.Clear>
)
}
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean
showClear?: boolean
}) {
return (
<InputGroup className={cn("w-auto", className)}>
<ComboboxPrimitive.Input
render={<InputGroupInput disabled={disabled} />}
{...props}
/>
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
size="icon-xs"
variant="ghost"
asChild
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
>
<ComboboxTrigger />
</InputGroupButton>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</InputGroup>
)
}
function ComboboxContent({
className,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
>) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn("group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded border-2 bg-popover text-popover-foreground shadow-md duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
)
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
className
)}
{...props}
/>
)
}
function ComboboxItem({
className,
children,
...props
}: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**: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",
className
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
)
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn(className)}
{...props}
/>
)
}
function ComboboxLabel({
className,
...props
}: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
)
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn(
"hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
className
)}
{...props}
/>
)
}
function ComboboxSeparator({
className,
...props
}: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn(
"flex min-h-8 flex-wrap items-center gap-1 rounded border-2 bg-input bg-clip-padding px-2.5 py-1 text-sm shadow-sm transition-colors focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-primary has-aria-invalid:border-destructive has-data-[slot=combobox-chip]:px-1",
className
)}
{...props}
/>
)
}
function ComboboxChip({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean
}) {
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm border-2 bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
className
)}
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<Button variant="ghost" size="icon-xs" />}
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
)
}
function ComboboxChipsInput({
className,
...props
}: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"
className={cn("min-w-16 flex-1 outline-none", className)}
{...props}
/>
)
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null)
}
export {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxGroup,
ComboboxLabel,
ComboboxCollection,
ComboboxEmpty,
ComboboxSeparator,
ComboboxChips,
ComboboxChip,
ComboboxChipsInput,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
}
임포트 경로를 프로젝트 구성에 맞게 수정합니다.
사용법#
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox"const frameworks = ["Next.js", "SvelteKit", "Nuxt.js", "Remix", "Astro"]
export function ExampleCombobox() {
return (
<Combobox items={frameworks}>
<ComboboxInput placeholder="Select a framework" />
<ComboboxContent>
<ComboboxEmpty>No items found.</ComboboxEmpty>
<ComboboxList>
{(item) => (
<ComboboxItem key={item} value={item}>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
)
}구성#
간단한 구성#
단일 행 입력과 평면 목록입니다(기본 참조).
Combobox
├── ComboboxInput
└── ComboboxContent
├── ComboboxEmpty
└── ComboboxList
├── ComboboxItem
└── ComboboxItem칩 포함#
multiple, 칩, 칩 입력을 사용한 다중 선택입니다(다중 참조).
Combobox
├── ComboboxChips
│ ├── ComboboxValue
│ │ └── ComboboxChip
│ └── ComboboxChipsInput
└── ComboboxContent
├── ComboboxEmpty
└── ComboboxList
├── ComboboxItem
└── ComboboxItem그룹 및 컬렉션 포함#
각 ComboboxGroup 안에서 ComboboxCollection 을 사용해 그룹별로 항목을 중첩하고, 그룹 사이에 구분선을 배치합니다(그룹 참조).
Combobox
├── ComboboxInput
└── ComboboxContent
├── ComboboxEmpty
└── ComboboxList
├── ComboboxGroup
│ ├── ComboboxLabel
│ └── ComboboxCollection
│ ├── ComboboxItem
│ └── ComboboxItem
├── ComboboxSeparator
└── ComboboxGroup
├── ComboboxLabel
└── ComboboxCollection
├── ComboboxItem
└── ComboboxItem커스텀 아이템#
항목이 객체인 경우 itemToStringValue 를 사용합니다.
import * as React from "react"
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox"
type Framework = {
label: string
value: string
}
const frameworks: Framework[] = [
{ label: "Next.js", value: "next" },
{ label: "SvelteKit", value: "sveltekit" },
{ label: "Nuxt", value: "nuxt" },
]
export function ExampleComboboxCustomItems() {
return (
<Combobox
items={frameworks}
itemToStringValue={(framework) => framework.label}
>
<ComboboxInput placeholder="Select a framework" />
<ComboboxContent>
<ComboboxEmpty>No items found.</ComboboxEmpty>
<ComboboxList>
{(framework) => (
<ComboboxItem key={framework.value} value={framework}>
{framework.label}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
)
}다중 선택#
다중 선택 동작에는 multiple 을 칩과 함께 사용합니다.
import * as React from "react"
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxValue,
} from "@/components/ui/combobox"
const frameworks = ["Next.js", "SvelteKit", "Nuxt.js", "Remix", "Astro"]
export function ExampleComboboxMultiple() {
const [value, setValue] = React.useState<string[]>([])
return (
<Combobox
items={frameworks}
multiple
value={value}
onValueChange={setValue}
>
<ComboboxChips>
<ComboboxValue>
{value.map((item) => (
<ComboboxChip key={item}>{item}</ComboboxChip>
))}
</ComboboxValue>
<ComboboxChipsInput placeholder="Add framework" />
</ComboboxChips>
<ComboboxContent>
<ComboboxEmpty>No items found.</ComboboxEmpty>
<ComboboxList>
{(item) => (
<ComboboxItem key={item} value={item}>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
)
}예제#
기본#
프레임워크 목록을 갖춘 간단한 combobox 입니다.
"use client"
import {다중#
multiple 과 ComboboxChips 를 사용한 다중 선택 combobox 입니다.
"use client"
import * as React from "react"지우기 버튼#
showClear prop 을 사용해 지우기 버튼을 표시합니다.
"use client"
import {그룹#
ComboboxGroup 과 ComboboxSeparator 를 사용해 항목을 그룹화합니다.
"use client"
import {커스텀 아이템#
ComboboxItem 안에 커스텀 컴포넌트를 렌더링할 수 있습니다.
"use client"
import {유효하지 않은 값#
aria-invalid prop 을 사용해 combobox 를 유효하지 않은 상태로 만듭니다.
"use client"
import {비활성화#
disabled prop 을 사용해 combobox 를 비활성화합니다.
"use client"
import {자동 하이라이트#
autoHighlight prop 을 사용하면 필터링 시 첫 번째 항목이 자동으로 하이라이트됩니다.
"use client"
import {팝업#
render prop 을 사용하면 버튼이나 다른 컴포넌트에서 combobox 를 열 수 있습니다. ComboboxInput 을 ComboboxContent 안으로 옮깁니다.
"use client"
import { Button } from "@/components/ui/button"입력 그룹#
ComboboxInput 안에서 InputGroupAddon 컴포넌트를 사용하면 combobox 에 애드온을 추가할 수 있습니다.
"use client"
import { GlobeIcon } from "lucide-react"RTL#
Neobrutalism에서 RTL 지원을 활성화하는 방법은 RTL 설정 가이드를 참고하세요.
"use client"
import * as React from "react"접근성#
Combobox는 WAI-ARIA Combobox 패턴을 구현합니다. 입력 요소가 role="combobox"를 갖고, aria-expanded와 함께 aria-activedescendant가 role="listbox" 팝업에서 강조된 옵션을 따라가므로, DOM 포커스는 입력 요소를 떠나지 않습니다.
키보드 상호작용:
| 키 | 동작 |
|---|---|
| 인쇄 가능한 문자 | 옵션 목록 필터링 |
ArrowDown / ArrowUp | 팝업을 열고 강조 항목을 아래/위로 이동 |
Enter | 강조된 옵션 확정 |
Escape | 값을 바꾸지 않고 팝업 닫기 |
칩 입력이 비어 있을 때 Backspace | 마지막 칩 제거(multiple 모드) |
API 참조#
자세한 내용은 Base UI 문서를 참고하세요.