For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Combobox

The Combobox component from this design system, built on Base UI.

Installation

$ pnpm dlx shadcn@latest add @cobalt/combobox

Source

"use client";

import { Combobox as ComboboxPrimitive } from "@base-ui/react";
import { RiCheckLine, RiArrowDownSLine, RiCloseLine } from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

const Combobox = ComboboxPrimitive.Root;

function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
  return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />;
}

/** Scroll distance, in px, over which the list's top fade ramps from 0 to 1. */
const SCROLL_FADE_DISTANCE = 24;

/**
 * The soft top edge of the list: a 24px masked 1px backdrop blur, a 16px
 * masked 4px blur closer to the edge, and a wash of the popup surface. Each
 * layer fades in over the first 24px scrolled, so the resting list keeps a
 * plain top edge. Opacity goes on each layer, never on the wrapper: a
 * translucent parent becomes the backdrop root and samples nothing.
 */
function ComboboxScrollFade({ progress }: { progress: number }) {
  return (
    <div
      aria-hidden
      className="pointer-events-none absolute inset-x-0 top-0 z-10 h-6"
    >
      <div
        className="absolute inset-0 backdrop-blur-[1px] [mask-image:linear-gradient(to_bottom,black,transparent)]"
        style={{ opacity: progress }}
      />
      <div
        className="absolute inset-x-0 top-0 h-4 backdrop-blur-[4px] [mask-image:linear-gradient(to_bottom,black,transparent)]"
        style={{ opacity: progress }}
      />
      <div
        className="absolute inset-0 bg-linear-to-b from-[var(--surface-raised,var(--popover))] to-transparent"
        style={{ opacity: progress }}
      />
    </div>
  );
}

function ComboboxTrigger({
  className,
  children,
  ...props
}: ComboboxPrimitive.Trigger.Props) {
  return (
    <ComboboxPrimitive.Trigger
      aria-label="Show suggestions"
      data-slot="combobox-trigger"
      className={cn(
        "group/combobox-trigger flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-[6px] p-1 text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:text-[color:var(--input-disabled-foreground,var(--muted-foreground))] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      {...props}
    >
      {children}
      <RiArrowDownSLine className="pointer-events-none size-4 transition-transform duration-200 ease-[ease] group-data-[popup-open]/combobox-trigger:rotate-180 motion-reduce:transition-none" />
    </ComboboxPrimitive.Trigger>
  );
}

function ComboboxClear({
  className,
  children,
  ...props
}: ComboboxPrimitive.Clear.Props) {
  return (
    <ComboboxPrimitive.Clear
      aria-label="Clear value"
      data-slot="combobox-clear"
      className={cn(
        "flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-[6px] p-1 text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:text-[color:var(--input-disabled-foreground,var(--muted-foreground))] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      {...props}
    >
      {children ?? <RiCloseLine className="pointer-events-none size-4" />}
    </ComboboxPrimitive.Clear>
  );
}

function ComboboxInput({
  className,
  children,
  disabled = false,
  showTrigger = true,
  showClear = false,
  size = "default",
  leadingIcon,
  ...props
}: Omit<ComboboxPrimitive.Input.Props, "size"> & {
  showTrigger?: boolean;
  showClear?: boolean;
  /** A 20px icon at the start of the field, such as a search glass. */
  leadingIcon?: React.ReactNode;
  /** `sm` is the source's 32px field; `default` is its 36px field. */
  size?: "default" | "sm";
}) {
  const actionCount = Number(showTrigger) + Number(showClear);
  const bothActions = showTrigger && showClear;

  return (
    <div
      data-slot="combobox-input"
      data-size={size}
      className={cn(
        "relative w-full",
        // The clear button only mounts once there is a value, so the extra
        // room for it is added by :has() rather than up front.
        bothActions && "has-data-[slot=combobox-clear]:[&>input]:pr-[54px]",
        className
      )}
    >
      <ComboboxPrimitive.Input
        disabled={disabled}
        render={
          <Input
            data-size={size}
            className={cn(
              size === "sm" ? "h-8 pl-2.5" : "pl-3",
              leadingIcon && "pl-[33px]",
              actionCount > 0 && (size === "sm" ? "pr-7" : "pr-6")
            )}
          />
        }
        {...props}
      />
      {leadingIcon ? (
        <span
          aria-hidden
          data-slot="combobox-leading-icon"
          className="pointer-events-none absolute inset-y-0 start-[9px] flex items-center text-[color:var(--foreground-subtle,var(--muted-foreground))] [&_svg]:size-5 [&_svg]:shrink-0"
        >
          {leadingIcon}
        </span>
      ) : null}
      {actionCount > 0 && (
        <div
          className={cn(
            "absolute inset-y-0 flex items-center gap-0.5",
            size === "sm" ? "right-0.5" : "right-1"
          )}
        >
          {showClear && <ComboboxClear disabled={disabled} />}
          {showTrigger && <ComboboxTrigger disabled={disabled} />}
        </div>
      )}
      {children}
    </div>
  );
}

function ComboboxContent({
  className,
  side = "bottom",
  sideOffset = 4,
  align = "start",
  alignOffset = 0,
  anchor,
  children,
  ...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 flex max-h-(--available-height) w-(--anchor-width) max-w-[calc(100vw-32px)] origin-(--transform-origin) flex-col overflow-hidden rounded-[16px] border border-[var(--border-subtle,var(--border))] bg-[var(--surface-raised,var(--popover))] p-2 text-popover-foreground shadow-[shadow:var(--shadow-dropdown,0_1px_1px_0_rgb(0_0_0/0.04),0_4px_4px_0_rgb(0_0_0/0.02))] transition-[opacity,scale,filter] duration-150 ease-out data-[chips=true]:min-w-(--anchor-width) data-ending-style:scale-95 data-ending-style:opacity-0 data-ending-style:blur-[2px] data-starting-style:scale-95 data-starting-style:opacity-0 data-starting-style:blur-[2px] motion-reduce:transition-none motion-reduce:data-ending-style:scale-100 motion-reduce:data-ending-style:opacity-100 motion-reduce:data-ending-style:blur-none motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:opacity-100 motion-reduce:data-starting-style:blur-none",
            className
          )}
          {...props}
        >
          {children}
        </ComboboxPrimitive.Popup>
      </ComboboxPrimitive.Positioner>
    </ComboboxPrimitive.Portal>
  );
}

function ComboboxList({
  className,
  children,
  onScroll,
  ...props
}: ComboboxPrimitive.List.Props) {
  const [progress, setProgress] = React.useState(0);

  return (
    <div className="relative flex min-h-0 flex-col">
      <ComboboxPrimitive.List
        data-slot="combobox-list"
        onScroll={(event) => {
          setProgress(
            Math.min(
              1,
              Math.max(0, event.currentTarget.scrollTop) / SCROLL_FADE_DISTANCE
            )
          );
          onScroll?.(event);
        }}
        className={cn(
          "flex max-h-[240px] min-h-0 w-full scroll-py-1 flex-col gap-1 overflow-y-auto overscroll-contain outline-none",
          className
        )}
        {...props}
      >
        {children}
      </ComboboxPrimitive.List>
      <ComboboxScrollFade progress={progress} />
    </div>
  );
}

function ComboboxItem({
  className,
  children,
  ...props
}: ComboboxPrimitive.Item.Props) {
  return (
    <ComboboxPrimitive.Item
      data-slot="combobox-item"
      className={cn(
        "flex w-full cursor-pointer items-center gap-2 rounded-[10px] p-2 text-start text-sm leading-5 font-medium text-foreground outline-none select-none transition-colors data-disabled:cursor-not-allowed data-disabled:text-[color:var(--foreground-disabled,var(--muted-foreground))] data-highlighted:bg-[var(--surface-hover,var(--accent))] motion-reduce:transition-none [&_svg]:pointer-events-none [&_svg]:shrink-0",
        className
      )}
      {...props}
    >
      <span className="flex min-w-0 flex-1 items-center gap-2">{children}</span>
      <ComboboxPrimitive.ItemIndicator
        render={
          <span className="flex size-4 shrink-0 items-center justify-center" />
        }
      >
        <RiCheckLine className="pointer-events-none size-4 text-ring" />
      </ComboboxPrimitive.ItemIndicator>
    </ComboboxPrimitive.Item>
  );
}

function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
  return (
    <ComboboxPrimitive.Group
      data-slot="combobox-group"
      className={cn(
        "flex flex-col gap-1 [&:not(:first-child)]:pt-2",
        className
      )}
      {...props}
    />
  );
}

function ComboboxLabel({
  className,
  ...props
}: ComboboxPrimitive.GroupLabel.Props) {
  return (
    <ComboboxPrimitive.GroupLabel
      data-slot="combobox-label"
      className={cn(
        "ps-2 pt-1 pb-0.5 text-sm leading-5 font-medium 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 items-center px-2 py-2 text-sm leading-5 font-medium text-[color:var(--combobox-empty-foreground,var(--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(
        // cobalt-allow-focus-within -- text input (chips hold the combobox input)
        "flex min-h-9 flex-wrap items-center gap-1.5 rounded-[8px] border border-input bg-transparent bg-clip-padding px-2.5 py-1.5 text-sm shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1.5 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
        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.5))] w-fit items-center justify-center gap-1 rounded-[6px] 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"
        >
          <RiCloseLine 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,
};