컴포넌트

Select

검색 없이 목록에서 하나 또는 여러 값을 선택합니다.

설치

pnpm dlx ply-ui add select

공통 설치 설정을 먼저 적용합니다.

사용법

options에 값과 이름을 전달하고 label로 입력의 이름을 지정합니다. defaultValue는 처음 선택한 값입니다. 여러 값을 선택할 때는 multiple을 지정하고 배열을 사용합니다.

Select는 검색 입력 없이 목록에서 선택합니다. 이름을 입력해 찾아야 한다면 Combobox, 작업을 실행하는 목록이라면 Menu를 사용합니다.

구성

버튼과 목록은 하나의 표면으로 연결됩니다. 단일 선택에서는 현재 값을 버튼에 남기고 목록에는 나머지 항목을 표시합니다. 다중 선택에서는 선택된 항목도 목록에 남아 해제할 수 있습니다.

부분역할
레이블어떤 값을 선택하는 입력인지 설명합니다.
버튼현재 값과 펼침 표시를 보여줍니다.
구분선현재 값과 선택 목록을 나눕니다. 목록의 이동 모션에는 따라가지 않습니다.
목록선택할 항목을 보여줍니다. 긴 이름은 줄바꿈합니다.
체크다중 선택에서 선택 여부를 항목 오른쪽에 표시합니다.
개수 배지선택한 이름 두 개 다음에 나머지 개수를 표시합니다.
스크롤 안내목록이 더 있을 때 위·아래 화살표와 그라디언트를 보여줍니다. 끝에 가까워지면 줄어듭니다.

다중 선택

multiple을 지정하면 선택값이 배열이 됩니다. 항목을 다시 누르면 해당 값만 해제되며 목록은 열린 상태를 유지합니다. 이름은 두 개까지 표시하고, 세 개부터 외 N개 배지를 붙입니다. 버튼이 좁으면 이름만 말줄임합니다.

크기 SM

기본 높이 32px입니다. 버튼과 목록의 크기를 함께 조정합니다.

크기 MD

기본 높이 40px입니다.

크기 LG

기본 높이 48px이며 기본 크기입니다. 긴 단일 선택 이름은 줄바꿈하면서 높이가 늘어납니다. 다중 선택은 이름을 말줄임해 높이를 유지합니다. 터치 환경에서는 모든 크기의 목록 항목을 최소 44px로 표시합니다. XS는 제공하지 않습니다.

긴 목록

목록 높이는 최대 240px입니다. 아래 공간이 부족하면 위로 펼쳐지고, 화면 안의 공간에 맞춰 목록 높이를 제한합니다. 검색이 필요한 목록은 Combobox를 사용합니다.

긴 이름과 선택 개수

다중 선택의 긴 이름은 말줄임하고 배지와 펼침 아이콘의 공간을 유지합니다. 항목의 전체 이름은 열린 목록에서 확인할 수 있습니다.

선택 전 안내

초기값을 생략하면 placeholder를 표시합니다.

비활성

disabled를 지정하면 열거나 선택할 수 없으며 폼 제출에서 제외됩니다. 특정 항목만 막으려면 해당 옵션에 disabled: true를 지정합니다.

읽기 전용

readOnly에서는 목록을 열어 확인할 수 있지만 선택값은 바뀌지 않습니다.

오류

error에 안내 문장을 지정하면 오류를 표시하고 입력과 연결합니다. required는 폼에서 필수 선택 여부를 검사합니다.

선택값 제어

valueonValueChange로 선택값을 외부에서 관리합니다. 제출하거나 저장하는 값은 화면의 이름이 아닌 옵션의 value입니다.

폼 제출과 초기화

name으로 폼 값을 제출합니다. 다중 선택은 같은 이름으로 값을 여러 개 제출합니다. defaultValue를 사용하면 폼 초기화 시 기본값으로 돌아갑니다. value를 외부에서 관리한다면 폼의 onReset에서 상위 상태도 초기화합니다.

키보드와 모션

방향키로 항목을 탐색하고 Enter로 선택합니다. 다중 선택에서는 같은 항목을 다시 선택해 해제할 수 있습니다. Escape는 목록을 닫고 버튼으로 포커스를 돌려주며, Tab은 다음 입력으로 이동합니다.

표면은 300ms 동안 펼쳐지고, 목록은 100ms 뒤에 이동·페이드를 시작합니다. 닫힐 때는 200ms를 사용합니다. 버튼과 구분선은 목록의 이동에 따라 움직이지 않습니다. 기기의 모션 줄이기 설정에서는 전환을 생략합니다.

크기·위치·다이얼로그·모션 비교 화면에서 같은 Select의 동작을 확인할 수 있습니다.

속성

Select

속성

타입

옵션

속성

타입

이전 사용법에서 전환

기존 Select.Root, Trigger, Popup 조립 방식과 시안의 Select.Attached<Select>로 통합했습니다. itemsoptions로, 버튼과 목록에 각각 지정하던 크기는 하나의 size로 옮깁니다. 아이콘과 체크, 스크롤 안내는 Select 내부에서 제공합니다.

구현 코드

components/ui/select.tsx
select.tsx
"use client";

import {
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
} from "react";
import { Select as Primitive } from "@base-ui/react/select";
import { Badge } from "./badge";
import { Icon } from "./icon";
import { cx } from "../../lib/cx";
import { usePortalStyle } from "../../lib/use-portal-style";
import "./select.css";

// Select와 요약 Badge가 사용하는 값만 복사합니다. 전체 토큰/아이콘은 복사하지 않습니다.
const portalTokens = [
  "--rbx-color-surface-200",
  "--rbx-color-content-emphasis",
  "--rbx-color-stroke-contrast-alpha",
  "--rbx-color-stroke-default",
  "--rbx-color-system-alert",
  "--rbx-color-state-hover",
  "--rbx-color-shift-200",
  "--rbx-focus-ring",
  "--rbx-shadow-transient-low",
  "--rbx-alpha-color-shadow-subtle",
  "--rbx-font-body",
  "--rbx-typography-body-large-font",
  "--rbx-typography-body-medium-font",
  "--rbx-typography-body-small-font",
  "--rbx-typography-label-small-font",
  "--rbx-motion-duration-panel",
  "--rbx-motion-duration-panel-exit",
  "--rbx-motion-ease-enter",
  "--rbx-motion-ease-exit",
  "--rbx-time-100",
  "--rbx-layer-popup",
] as const;

export type SelectOption = {
  value: string;
  label: string;
  disabled?: boolean;
};
export type SelectProps<Multiple extends boolean = false> = Omit<
  Primitive.Root.Props<string, Multiple>,
  "children" | "items" | "actionsRef" | "modal" | "highlightItemOnHover"
> & {
  options: readonly SelectOption[];
  label: string;
  placeholder?: string;
  /** 버튼과 목록의 크기입니다. @defaultValue "lg" */
  size?: "sm" | "md" | "lg";
  description?: string;
  error?: string;
  invalid?: boolean;
  className?: string;
  /** 기본값은 화면 경계입니다. 제한된 편집 영역에서는 별도 경계를 지정할 수 있습니다. */
  collisionBoundary?: Primitive.Positioner.Props["collisionBoundary"];
  /** 모션 비교용 배속입니다. 기본값은 1입니다. */
  motionScale?: number;
};

/** Base UI의 값·폼·키보드 처리를 사용하는 연결형 Select입니다. */
export function Select<Multiple extends boolean = false>({
  options,
  label,
  placeholder = "선택하세요",
  size = "lg",
  description,
  error,
  invalid,
  className,
  collisionBoundary,
  motionScale = 1,
  value: controlledValue,
  defaultValue,
  onValueChange,
  open: controlledOpen,
  defaultOpen = false,
  onOpenChange,
  onOpenChangeComplete,
  disabled = false,
  readOnly = false,
  multiple,
  id: suppliedId,
  ...rootProps
}: SelectProps<Multiple>) {
  const autoId = useId();
  const id = suppliedId ?? autoId;
  type Value = Primitive.Root.Props<string, Multiple>["value"];
  const [internalValue, setInternalValue] = useState<Value>(
    defaultValue ?? ((multiple ? [] : null) as Value),
  );
  const value = controlledValue !== undefined ? controlledValue : internalValue;
  const [internalOpen, setInternalOpen] = useState(defaultOpen);
  const open = !disabled && (controlledOpen ?? internalOpen);
  const [hiddenValue, setHiddenValue] = useState(value);
  const triggerRef = useRef<HTMLButtonElement>(null);
  const scrollRef = useRef<HTMLDivElement>(null);
  const [popup, setPopup] = useState<HTMLDivElement | null>(null);
  const portalStyle = usePortalStyle(triggerRef, open || !!popup, portalTokens);
  const [headerHeight, setHeaderHeight] = useState(
    { sm: 32, md: 40, lg: 48 }[size],
  );
  const [edges, setEdges] = useState({ up: 0, down: 0 });
  const [contentHeight, setContentHeight] = useState(240);
  const [pointer, setPointer] = useState(false);
  const pendingKeyboard = useRef(false);
  const values = (
    multiple
      ? Array.isArray(value)
        ? value
        : []
      : value == null
        ? []
        : [value]
  ) as string[];
  const labels = values.map(
    (entry) => options.find((option) => option.value === entry)?.label ?? entry,
  );
  const summaryLabel = labels.length
    ? labels.slice(0, 2).join(", ") +
      (labels.length > 2 ? ` 외 ${labels.length - 2}개` : "")
    : placeholder;

  function measureEdges() {
    const node = scrollRef.current;
    if (!node) return;
    // Base UI가 닫힌 목록을 숨겨 보관할 때의 0px 측정은 버립니다.
    // 마지막 전체 높이를 유지해야 다시 열 때 방향이 바뀌지 않습니다.
    if (node.scrollHeight > 0) setContentHeight(node.scrollHeight);
    const max = Math.max(0, node.scrollHeight - node.clientHeight);
    const position = Math.max(0, Math.min(max, node.scrollTop));
    const strength = (distance: number) => {
      if (distance <= 0.5 || max <= 1) return 0;
      const x = Math.min(1, distance / 48);
      return x * x * (3 - 2 * x);
    };
    const next = { up: strength(position), down: strength(max - position) };
    setEdges((old) =>
      old.up === next.up && old.down === next.down ? old : next,
    );
  }
  function revealFocused(element: HTMLElement) {
    const scroll = scrollRef.current;
    if (!scroll || element.hidden) return;
    const item = element.getBoundingClientRect();
    const box = scroll.getBoundingClientRect();
    const margin = Math.min(32, Math.max(0, (box.height - item.height) / 2));
    if (item.top < box.top + margin)
      scroll.scrollTop += item.top - box.top - margin;
    else if (item.bottom > box.bottom - margin)
      scroll.scrollTop += item.bottom - box.bottom + margin;
  }
  function scrollBy(direction: number) {
    const node = scrollRef.current;
    if (!node) return;
    node.focus({ preventScroll: true });
    node.scrollBy({
      top: node.clientHeight * direction * 0.65,
      behavior: matchMedia("(prefers-reduced-motion:reduce)").matches
        ? "instant"
        : "smooth",
    });
  }
  useLayoutEffect(() => {
    // 외부에서 open/value를 바꿔도 현재 값은 목록에서 숨깁니다.
    // 닫히는 동안에는 이전 목록을 유지해 퇴장 모션이 흔들리지 않게 합니다.
    if (open) setHiddenValue(value);
  }, [open, value]);
  useLayoutEffect(() => {
    const trigger = triggerRef.current;
    if (!trigger) return;
    const measure = () => setHeaderHeight(trigger.offsetHeight);
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(trigger);
    return () => observer.disconnect();
  }, [size]);
  useLayoutEffect(() => {
    if (!open || !popup) return;
    const scroll = scrollRef.current;
    if (!scroll) return;
    measureEdges();
    const observer = new ResizeObserver(measureEdges);
    observer.observe(scroll);
    if (scroll.firstElementChild) observer.observe(scroll.firstElementChild);
    const frame = requestAnimationFrame(() => {
      const focused = document.activeElement;
      // 숨긴 현재 값에 초기 포커스를 시도한 경우에만 보완합니다.
      if (
        !multiple &&
        pendingKeyboard.current &&
        !(
          focused instanceof HTMLElement &&
          focused.matches(".rbx-attached-option:not([hidden])")
        )
      ) {
        scroll
          .querySelector<HTMLElement>(
            ".rbx-attached-option:not([hidden]):not([data-disabled])",
          )
          ?.focus({ preventScroll: true });
      }
      pendingKeyboard.current = false;
    });
    return () => {
      observer.disconnect();
      cancelAnimationFrame(frame);
    };
  }, [open, popup, multiple]);
  useEffect(() => {
    const trigger = triggerRef.current;
    const form = rootProps.form
      ? document.getElementById(rootProps.form)
      : trigger?.closest("form");
    if (!(form instanceof HTMLFormElement)) return;
    const reset = (event: Event) =>
      queueMicrotask(() => {
        if (event.defaultPrevented) return;
        if (controlledValue === undefined)
          setInternalValue(defaultValue ?? ((multiple ? [] : null) as Value));
        if (controlledOpen === undefined) setInternalOpen(false);
      });
    form.addEventListener("reset", reset);
    return () => form.removeEventListener("reset", reset);
  }, [controlledValue, controlledOpen, defaultValue, multiple, rootProps.form]);

  const summary = (
    <span className="rbx-attached-summary" aria-label={summaryLabel}>
      <span className="rbx-attached-names">
        {labels.length ? (
          labels.slice(0, 2).map((text, index) => (
            <span key={`${index}-${text}`} className="rbx-attached-name-entry">
              <span className="rbx-attached-name" title={text}>
                {text}
              </span>
              {index < Math.min(2, labels.length) - 1 && <span>,</span>}
            </span>
          ))
        ) : (
          <span className="rbx-attached-name">{placeholder}</span>
        )}
      </span>
      {labels.length > 2 && (
        <Badge size="sm" className="rbx-attached-count">
{labels.length - 2}
        </Badge>
      )}
    </span>
  );
  const motion = {
    "--attached-header": `${headerHeight}px`,
    "--attached-content": `${contentHeight}px`,
    "--attached-speed": motionScale,
  } as CSSProperties;
  const describedBy =
    [description && `${id}-description`, error && `${id}-error`]
      .filter(Boolean)
      .join(" ") || undefined;

  return (
    <div className={cx("rbx-attached-field", className)}>
      <label id={`${id}-label`} htmlFor={id} className="rbx-attached-label">
        {label}
        {rootProps.required && <span aria-hidden="true"> *</span>}
      </label>
      <Primitive.Root<string, Multiple>
        {...rootProps}
        id={id}
        items={options}
        multiple={multiple}
        disabled={disabled}
        readOnly={readOnly}
        value={value}
        open={open}
        modal={false}
        highlightItemOnHover={false}
        onValueChange={(next, details) => {
          onValueChange?.(next, details);
          if (!details.isCanceled && controlledValue === undefined)
            setInternalValue(next as Value);
        }}
        onOpenChange={(next, details) => {
          onOpenChange?.(next, details);
          if (details.isCanceled) return;
          if (next) {
            setHiddenValue(value);
            pendingKeyboard.current = details.event.type.startsWith("key");
            setPointer(false);
          }
          if (controlledOpen === undefined) setInternalOpen(next);
        }}
        onOpenChangeComplete={onOpenChangeComplete}
      >
        <Primitive.Trigger
          ref={triggerRef}
          className="rbx-attached-trigger"
          data-size={size}
          data-multiple={multiple ? "" : undefined}
          aria-labelledby={`${id}-label ${id}-value`}
          aria-describedby={describedBy}
          aria-invalid={invalid || !!error || undefined}
        >
          <Primitive.Value id={`${id}-value`}>{summary}</Primitive.Value>
          <Icon name="icon-regular-chevron-large-down" size={16} />
        </Primitive.Trigger>
        <Primitive.Portal>
          <Primitive.Positioner
            alignItemWithTrigger={false}
            side="bottom"
            align="start"
            sideOffset={-headerHeight}
            collisionPadding={8}
            collisionBoundary={collisionBoundary}
            collisionAvoidance={{
              side: "flip",
              align: "shift",
              fallbackAxisSide: "none",
            }}
            positionMethod="fixed"
            className="rbx-attached-positioner"
            data-theme={portalStyle.theme}
            style={{ ...portalStyle.style, ...motion }}
          >
            <Primitive.Popup
              ref={setPopup}
              finalFocus={triggerRef}
              className="rbx-attached-popup"
              data-invalid={invalid || !!error ? "" : undefined}
              data-size={size}
              data-multiple={multiple ? "" : undefined}
              data-pointer={pointer ? "" : undefined}
              onPointerMoveCapture={() => setPointer(true)}
              onPointerDownCapture={() => setPointer(true)}
              onKeyDownCapture={() => setPointer(false)}
            >
              <div className="rbx-attached-header" aria-hidden="true">
                {summary}
                <Icon name="icon-regular-chevron-large-down" size={16} />
              </div>
              <div className="rbx-attached-reveal">
                <div className="rbx-attached-clip">
                  <div
                    className="rbx-attached-list-shell"
                    style={
                      {
                        "--attached-up": edges.up,
                        "--attached-down": edges.down,
                      } as CSSProperties
                    }
                  >
                    <Primitive.List
                      ref={scrollRef}
                      className="rbx-attached-list"
                      tabIndex={-1}
                      aria-labelledby={`${id}-label`}
                      onScroll={measureEdges}
                    >
                      <div>
                        {options.map((option) => (
                          <Primitive.Item
                            key={option.value}
                            value={option.value}
                            label={option.label}
                            disabled={option.disabled}
                            hidden={!multiple && hiddenValue === option.value}
                            className="rbx-attached-option"
                            onFocus={(event) => {
                              if (event.currentTarget.matches(":focus-visible"))
                                revealFocused(event.currentTarget);
                            }}
                          >
                            <Primitive.ItemText className="rbx-attached-option-text">
                              {option.label}
                            </Primitive.ItemText>
                            {multiple && (
                              <span className="rbx-attached-check">
                                <Primitive.ItemIndicator>
                                  <Icon name="icon-filled-check" size={20} />
                                </Primitive.ItemIndicator>
                              </span>
                            )}
                          </Primitive.Item>
                        ))}
                      </div>
                    </Primitive.List>
                    <button
                      type="button"
                      className="rbx-attached-edge"
                      data-edge="up"
                      aria-hidden={edges.up === 0}
                      disabled={edges.up === 0}
                      tabIndex={-1}
                      aria-label="목록 위로 스크롤"
                      onClick={() => scrollBy(-1)}
                    >
                      <Icon name="icon-regular-chevron-large-up" size={16} />
                    </button>
                    <button
                      type="button"
                      className="rbx-attached-edge"
                      data-edge="down"
                      aria-hidden={edges.down === 0}
                      disabled={edges.down === 0}
                      tabIndex={-1}
                      aria-label="목록 아래로 스크롤"
                      onClick={() => scrollBy(1)}
                    >
                      <Icon name="icon-regular-chevron-large-down" size={16} />
                    </button>
                  </div>
                </div>
              </div>
            </Primitive.Popup>
          </Primitive.Positioner>
        </Primitive.Portal>
      </Primitive.Root>
      {description && (
        <p id={`${id}-description`} className="rbx-attached-description">
          {description}
        </p>
      )}
      {error && (
        <p id={`${id}-error`} className="rbx-attached-error" role="alert">
          {error}
        </p>
      )}
    </div>
  );
}
components/ui/select.css
select.css
.rbx-attached-field {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: 8px;
  min-width: 0;
}
.rbx-attached-label {
  font: var(--rbx-typography-body-medium-font);
  color: var(--rbx-color-content-emphasis);
}
.rbx-attached-trigger,
.rbx-attached-header {
  box-sizing: border-box;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 0 16px;
  color: var(--rbx-color-content-emphasis);
  font: var(--rbx-typography-body-large-font);
}
.rbx-attached-trigger {
  min-width: 0;
  width: 100%;
  min-height: 48px;
  border: 1px solid var(--rbx-color-stroke-contrast-alpha);
  border-radius: 12px;
  background: var(--rbx-color-surface-200);
  cursor: pointer;
  text-align: start;
}
.rbx-attached-trigger[data-size="md"] {
  min-height: 40px;
  padding-inline: 12px;
  font: var(--rbx-typography-body-medium-font);
  border-radius: 10px;
}
.rbx-attached-trigger[data-size="sm"] {
  min-height: 32px;
  padding-inline: 12px;
  font: var(--rbx-typography-body-small-font);
  border-radius: 8px;
}
.rbx-attached-trigger:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
.rbx-attached-trigger[aria-invalid="true"],
.rbx-attached-popup[data-invalid] {
  border-color: var(--rbx-color-system-alert);
}
.rbx-attached-trigger:focus-visible {
  outline: 2px solid var(--rbx-focus-ring);
  outline-offset: 2px;
}
.rbx-attached-trigger > span:first-child {
  flex: 1;
  min-width: 0;
}
.rbx-attached-trigger > .rbx-icon,
.rbx-attached-header > .rbx-icon {
  flex-shrink: 0;
}
.rbx-attached-summary {
  display: flex;
  align-items: center;
  gap: 8px;
  flex: 1;
  min-width: 0;
}
.rbx-attached-names {
  display: flex;
  align-items: center;
  gap: 4px;
  flex: 1;
  min-width: 0;
}
.rbx-attached-name-entry {
  display: flex;
  flex: 0 1 auto;
  min-width: 0;
}
.rbx-attached-name {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.rbx-attached-count,
.rbx-attached-name-entry > span:last-child:not(.rbx-attached-name) {
  flex-shrink: 0;
}
.rbx-attached-trigger[data-size="sm"] .rbx-attached-count {
  height: 20px;
  padding: 2px 6px;
}
.rbx-attached-description,
.rbx-attached-error {
  margin: 0;
  font: var(--rbx-typography-body-small-font);
}
.rbx-attached-description {
  color: var(--rbx-color-content-default);
}
.rbx-attached-error {
  color: var(--rbx-color-system-alert);
}
.rbx-attached-positioner {
  z-index: var(--rbx-layer-popup);
  pointer-events: none;
  outline: none;
  /* 배치 계산에는 펼쳐진 높이를 사용합니다. 모션 도중 방향이 뒤집히지 않습니다. */
  --attached-list-height: min(
    240px,
    var(--attached-content, 240px),
    max(
      0px,
      calc(var(--available-height, 100vh) - var(--attached-header) - 1px)
    )
  );
  height: calc(var(--attached-header) + var(--attached-list-height) + 1px);
  --attached-enter: calc(
    var(--rbx-motion-duration-panel) * var(--attached-speed, 1)
  );
  --attached-exit: calc(
    var(--rbx-motion-duration-panel-exit) * var(--attached-speed, 1)
  );
  --attached-follow: calc(var(--rbx-time-100) * var(--attached-speed, 1));
}
.rbx-attached-positioner[data-anchor-hidden] {
  visibility: hidden;
}
/* 포털 표면을 버튼 위치까지 겹쳐 그립니다. 헤더 클릭은 원래 Trigger로 전달됩니다. */
.rbx-attached-popup {
  box-sizing: border-box;
  width: var(--anchor-width);
  display: flex;
  flex-direction: column;
  height: calc(var(--attached-header) + var(--attached-list-height) + 1px);
  border: 1px solid var(--rbx-color-stroke-contrast-alpha);
  border-radius: 12px;
  background: var(--rbx-color-surface-200);
  color: var(--rbx-color-content-emphasis);
  /* 전체 표면에는 얕은 그림자, 목록 아래에는 한 겹 더해 배경과 분리합니다. */
  box-shadow:
    var(--rbx-shadow-transient-low),
    0 4px 8px var(--rbx-alpha-color-shadow-subtle);
  /* hidden은 포커스 이동으로 스크롤될 수 있습니다. 표면은 자르기만 합니다. */
  overflow: clip;
  outline: none;
  pointer-events: none;
  transition:
    height var(--attached-enter) var(--rbx-motion-ease-enter),
    box-shadow var(--attached-enter) var(--rbx-motion-ease-enter);
}
.rbx-attached-popup[data-size="md"] {
  border-radius: 10px;
}
.rbx-attached-popup[data-size="sm"] {
  border-radius: 8px;
}
.rbx-attached-header {
  min-width: 0;
  flex: 0 0 calc(var(--attached-header) - 2px);
  height: calc(var(--attached-header) - 2px);
}
.rbx-attached-header > .rbx-icon {
  rotate: 180deg;
}
.rbx-attached-popup[data-size="md"] .rbx-attached-header {
  padding-inline: 12px;
  font: var(--rbx-typography-body-medium-font);
}
.rbx-attached-popup[data-size="sm"] .rbx-attached-header {
  padding-inline: 12px;
  font: var(--rbx-typography-body-small-font);
}
.rbx-attached-popup[data-size="lg"] .rbx-attached-header {
  padding-inline: 16px;
}
.rbx-attached-popup[data-size="sm"] .rbx-attached-count {
  height: 20px;
  padding: 2px 6px;
}
.rbx-attached-reveal {
  position: relative;
  flex: 1;
  min-height: 0;
  overflow: clip;
}
.rbx-attached-clip {
  height: 100%;
  min-height: 0;
  overflow: clip;
}
.rbx-attached-list-shell {
  position: relative;
  padding-top: 1px;
  opacity: 1;
  transform: translateY(0);
  transition:
    transform var(--attached-enter) var(--rbx-motion-ease-enter)
      var(--attached-follow),
    opacity var(--attached-enter) var(--rbx-motion-ease-enter)
      var(--attached-follow);
}
.rbx-attached-list {
  box-sizing: border-box;
  max-height: var(--attached-list-height);
  overflow: auto;
  overscroll-behavior: contain;
  touch-action: pan-y;
  scrollbar-width: none;
  padding: 8px;
  scroll-padding-block: 32px;
  outline: none;
  pointer-events: auto;
}
.rbx-attached-list::-webkit-scrollbar {
  display: none;
}
.rbx-attached-option {
  box-sizing: border-box;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px 8px;
  min-height: 44px;
  border-radius: 6px;
  font: var(--rbx-typography-body-medium-font);
  color: var(--rbx-color-content-emphasis);
  cursor: pointer;
  user-select: none;
  outline: none;
}
.rbx-attached-option[hidden] {
  display: none;
}
.rbx-attached-popup[data-size="md"] .rbx-attached-option {
  min-height: 36px;
  padding-block: 8px;
}
.rbx-attached-popup[data-size="sm"] .rbx-attached-option {
  min-height: 32px;
  padding-block: 6px;
  font: var(--rbx-typography-body-small-font);
}
.rbx-attached-option-text {
  min-width: 0;
  flex: 1;
  overflow-wrap: anywhere;
}
.rbx-attached-option:hover:not([data-disabled]) {
  background: var(--rbx-color-state-hover);
}
.rbx-attached-option[data-disabled] {
  opacity: 0.45;
  cursor: not-allowed;
}
.rbx-attached-popup:not([data-pointer]) .rbx-attached-option:focus-visible {
  outline: 2px solid var(--rbx-focus-ring);
  outline-offset: -2px;
}
.rbx-attached-check {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 20px;
  height: 20px;
  flex-shrink: 0;
}
.rbx-attached-edge {
  position: absolute;
  z-index: 1;
  inset-inline: 0;
  height: calc(28px * var(--attached-strength));
  opacity: var(--attached-strength);
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 0;
  padding: 0;
  color: var(--rbx-color-content-emphasis);
  cursor: pointer;
  pointer-events: auto;
  transition:
    height var(--attached-follow) var(--rbx-motion-ease-enter),
    opacity var(--attached-follow) var(--rbx-motion-ease-enter);
}
.rbx-attached-edge:disabled {
  pointer-events: none;
}
.rbx-attached-edge > .rbx-icon {
  flex-shrink: 0;
}
.rbx-attached-edge[data-edge="up"] {
  --attached-strength: var(--attached-up, 0);
  top: 0;
  background: linear-gradient(
    to bottom,
    var(--rbx-color-surface-200) 45%,
    transparent
  );
}
.rbx-attached-edge[data-edge="down"] {
  --attached-strength: var(--attached-down, 0);
  bottom: 0;
  background: linear-gradient(
    to top,
    var(--rbx-color-surface-200) 45%,
    transparent
  );
}
/* 헤더와 구분선은 이동하지 않습니다. 배경 높이와 목록의 모션만 분리합니다. */
.rbx-attached-reveal::before {
  content: "";
  position: absolute;
  z-index: 2;
  inset: 0 0 auto;
  height: 1px;
  background: var(--rbx-color-stroke-default);
  pointer-events: none;
}
.rbx-attached-popup[data-side="top"] {
  position: absolute;
  bottom: 0;
  flex-direction: column-reverse;
}
.rbx-attached-popup[data-side="top"] .rbx-attached-header > .rbx-icon {
  rotate: 0deg;
}
.rbx-attached-popup[data-side="top"] .rbx-attached-reveal::before {
  inset: auto 0 0;
}
.rbx-attached-popup[data-side="top"] .rbx-attached-list-shell {
  padding-top: 0;
  padding-bottom: 1px;
}
.rbx-attached-popup:is([data-starting-style], [data-ending-style]) {
  height: var(--attached-header);
  box-shadow: none;
}
.rbx-attached-popup:is([data-starting-style], [data-ending-style])
  .rbx-attached-list-shell {
  opacity: 0;
  transform: translateY(12px);
}
.rbx-attached-popup[data-side="top"]:is(
    [data-starting-style],
    [data-ending-style]
  )
  .rbx-attached-list-shell {
  transform: translateY(-12px);
}
.rbx-attached-popup[data-ending-style],
.rbx-attached-popup[data-ending-style] .rbx-attached-list-shell {
  transition-duration: var(--attached-exit);
  transition-delay: 0ms;
  transition-timing-function: var(--rbx-motion-ease-exit);
  pointer-events: none;
}
.rbx-attached-popup[data-ending-style] * {
  pointer-events: none;
}
@media (pointer: coarse) {
  .rbx-attached-popup[data-size] .rbx-attached-option {
    min-height: 44px;
  }
}
@media (prefers-reduced-motion: reduce) {
  .rbx-attached-popup,
  .rbx-attached-popup * {
    transition: none !important;
  }
}

/* 단일 값은 전체 이름을 줄바꿈하고, 다중 요약은 높이를 유지합니다. */
.rbx-attached-trigger:not([data-multiple]) {
  padding-block: 8px;
}
.rbx-attached-trigger:not([data-multiple])[data-size="sm"] {
  padding-block: 4px;
}
.rbx-attached-trigger:not([data-multiple])[data-size="md"] {
  padding-block: 6px;
}
:is(.rbx-attached-trigger, .rbx-attached-popup):not([data-multiple])
  .rbx-attached-name {
  white-space: normal;
  overflow-wrap: anywhere;
}

.rbx-attached-trigger[data-multiple] {
  height: 48px;
}
.rbx-attached-trigger[data-multiple][data-size="md"] {
  height: 40px;
}
.rbx-attached-trigger[data-multiple][data-size="sm"] {
  height: 32px;
}

목차