컴포넌트
셀렉트
목록에서 하나를 고르고, 고른 값이 위아래로 굴러 바뀌는 선택 상자예요.
- #arc
- #component
- #select
- #dropdown
- #form
- #choice
1280px
설치
이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.
터미널터미널터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/select.json파일
components/arc/select/select.module.csscomponents/arc/select/select.tsx
npm 패키지
@radix-ui/react-select@^2.4.1motion@^14.2.0
함께 오는 항목
select.module.cssselect.module.cssselect.module.csscss
.field { display: grid; gap: var(--space-2); min-width: 0; }
.field label { font-size: var(--text-sm); font-weight: var(--label-weight); }
/* The trigger anchors the floating menu. Radix opens on pointerdown and measures this box, so it never scales: press feedback is color only. */
.trigger { position: relative; box-sizing: border-box; display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); width: 100%; min-height: var(--control-height-md); border: 1px solid var(--border); border-radius: var(--radius-control); padding: 0 var(--space-3); background: var(--surface); color: var(--foreground); font: inherit; font-size: var(--text-sm); text-align: left; cursor: pointer; transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); }
@media (hover: hover) and (pointer: fine) { .trigger:hover:not([data-disabled]) { border-color: var(--border-strong); background: var(--surface-muted); } }
.trigger:active:not([data-disabled]), .trigger[data-state="open"] { border-color: var(--border-strong); background: var(--surface-muted); }
.trigger:focus-visible { border-color: var(--border-strong); }
.trigger[data-disabled] { opacity: .5; cursor: not-allowed; }
.valueText { display: grid; flex: 1; min-width: 0; grid-template-columns: minmax(0, 1fr); }
.valueText > span { grid-area: 1 / 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.valueText > [data-placeholder] { color: var(--text-muted); }
.srOnly { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
.chevron { display: inline-flex; flex: 0 0 auto; color: var(--text-muted); transition: transform var(--duration-spring) var(--ease-spring), color var(--duration-fast) var(--ease-standard); }
.trigger[data-state="open"] .chevron { transform: rotate(180deg); }
.hint { color: var(--text-muted); font-size: var(--text-xs); }
/* Radix holds the animation until the menu is positioned, so it always grows from the trigger edge. */
.content { --menu-x: 0px; --menu-y: -6px; box-sizing: border-box; z-index: 1000; width: var(--radix-select-trigger-width); min-width: var(--radix-select-trigger-width); max-width: min(24rem, calc(100vw - 20px)); max-height: min(320px, var(--radix-select-content-available-height)); overflow: hidden; border: 1px solid var(--float-edge, var(--surface-raised)); border-radius: calc(var(--radius-control) + 2px); padding: 6px; background: var(--surface-raised); color: var(--foreground); box-shadow: var(--shadow-floating); transform-origin: var(--radix-select-content-transform-origin); animation: select-in var(--duration-standard) var(--ease-enter) both; }
.content[data-side="top"] { --menu-y: 6px; }
.content[data-side="left"] { --menu-x: 6px; --menu-y: 0px; }
.content[data-side="right"] { --menu-x: -6px; --menu-y: 0px; }
.content[data-state="closed"] { animation: select-out var(--duration-instant) var(--ease-standard) both; }
.viewport { padding: 2px 0; }
.item { position: relative; display: flex; align-items: center; min-height: 36px; border-radius: calc(var(--radius-control) - 4px); padding: 0 34px 0 11px; color: var(--foreground); font-size: var(--text-sm); cursor: default; outline: none; user-select: none; transition: background-color var(--duration-instant) var(--ease-standard), color var(--duration-instant) var(--ease-standard); }
.item[data-highlighted] { background: var(--surface-muted); }
.item[data-disabled] { opacity: .45; }
.indicator { position: absolute; right: 10px; display: inline-flex; align-items: center; color: var(--foreground); animation: indicator-in var(--duration-standard) var(--ease-enter) 40ms both; }
.scrollButton { display: grid; height: 28px; place-items: center; color: var(--text-muted); }
@keyframes select-in { from { opacity: 0; transform: translate(var(--menu-x), var(--menu-y)) scale(.97); } }
@keyframes select-out { to { opacity: 0; transform: translate(calc(var(--menu-x) / 2), calc(var(--menu-y) / 2)) scale(.98); } }
@keyframes indicator-in { from { opacity: 0; transform: scale(.6); filter: blur(2px); } }
@media (prefers-reduced-transparency: reduce) { .content { background: var(--surface-raised); backdrop-filter: none; -webkit-backdrop-filter: none; } }
@media (prefers-reduced-motion: reduce) { .trigger, .chevron, .item { transition: none; } .content { animation: select-fade var(--duration-instant) var(--ease-standard) both; } .content[data-state="closed"] { animation: select-fade-out var(--duration-instant) var(--ease-standard) both; } .indicator { animation: none; } }
@keyframes select-fade { from { opacity: 0; } }
@keyframes select-fade-out { to { opacity: 0; } }
select.tsxselect.tsxselect.tsxtsx
"use client";
import { forwardRef, useId, useRef, useState } from "react";
import type { ComponentPropsWithoutRef } from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { AnimatePresence, motion } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import type { Variants } from "motion/react";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./select.module.css";
import { playUiSound } from "../sound/play";
import { UiIcon } from "../theme/ui-icon";
export interface SelectProps extends Omit<ComponentPropsWithoutRef<typeof SelectPrimitive.Root>, "children"> {
label: string;
description?: string;
placeholder?: string;
id?: string;
className?: string;
options: { value: string; label: string; disabled?: boolean }[];
}
/** The shown value rolls in the direction of the list: a later option rises from below, an earlier one drops from above. */
const valueRoll: Variants = {
enter: (direction: number) => ({ opacity: 0, y: `${direction * 0.35}em`, filter: `blur(${motionTokens.blur.soft}px)` }),
center: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } },
exit: (direction: number) => ({ opacity: 0, y: `${direction * -0.3}em`, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } }),
};
/** Reduced motion keeps a short crossfade; the resting state matches valueRoll so server and client markup agree. */
const valueFade: Variants = { enter: { opacity: 0 }, center: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.instant } }, exit: { opacity: 0, transition: { duration: motionTokens.duration.instant } } };
export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select(
{ label, description, placeholder = "선택해 주세요", options, id, className, disabled, onValueChange, ...rootProps },
ref,
) {
const generatedId = useId();
const controlId = id ?? generatedId;
const hintId = description ? `${controlId}-description` : undefined;
const reduceMotion = useReducedMotion();
// Radix closes the list a moment before it reports the chosen value, so the close sound waits one microtask to see whether a choice follows (a choice plays only "select").
const picked = useRef(false);
const [uncontrolledValue, setUncontrolledValue] = useState(rootProps.defaultValue ?? "");
const currentValue = rootProps.value ?? uncontrolledValue;
const index = options.findIndex((option) => option.value === currentValue);
const shown = currentValue ? options[index]?.label ?? "" : placeholder;
const [previousIndex, setPreviousIndex] = useState(index);
const [direction, setDirection] = useState(1);
if (previousIndex !== index) { setPreviousIndex(index); setDirection(index > previousIndex ? 1 : -1); }
return (
<div className={styles.field}>
<label htmlFor={controlId}>{label}</label>
<SelectPrimitive.Root {...rootProps} disabled={disabled} onOpenChange={(next) => { if (next) { picked.current = false; playUiSound("open"); } else queueMicrotask(() => { if (!picked.current) playUiSound("close"); picked.current = false; }); rootProps.onOpenChange?.(next); }} onValueChange={(next) => { picked.current = true; setUncontrolledValue(next); playUiSound("select"); onValueChange?.(next); }}>
<SelectPrimitive.Trigger
ref={ref}
id={controlId}
aria-describedby={hintId}
className={[styles.trigger, className].filter(Boolean).join(" ")}
>
{/* Radix keeps the real value for assistive tech; the visible copy below animates between values. */}
<span className={styles.srOnly}><SelectPrimitive.Value placeholder={placeholder} /></span>
<span className={styles.valueText} aria-hidden="true">
<AnimatePresence initial={false} custom={direction}>
<motion.span key={currentValue ? `value-${currentValue}` : "placeholder"} data-placeholder={currentValue ? undefined : ""} custom={direction} variants={reduceMotion ? valueFade : valueRoll} initial="enter" animate="center" exit="exit">{shown}</motion.span>
</AnimatePresence>
</span>
<SelectPrimitive.Icon className={styles.chevron}>
<UiIcon iconRole="chevron-down" size={16} strokeWidth={1.75} aria-hidden="true" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal>
<SelectPrimitive.Content className={styles.content} position="popper" sideOffset={4} collisionPadding={12}>
<SelectPrimitive.ScrollUpButton className={styles.scrollButton}>
<UiIcon iconRole="chevron-up" size={16} strokeWidth={1.75} aria-hidden="true" />
</SelectPrimitive.ScrollUpButton>
<SelectPrimitive.Viewport className={styles.viewport}>
{options.map((option) => (
<SelectPrimitive.Item key={option.value} value={option.value} disabled={option.disabled} className={styles.item}>
<SelectPrimitive.ItemText>{option.label}</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator className={styles.indicator}>
<UiIcon iconRole="check" size={16} strokeWidth={1.75} aria-hidden="true" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
))}
</SelectPrimitive.Viewport>
<SelectPrimitive.ScrollDownButton className={styles.scrollButton}>
<UiIcon iconRole="chevron-down" size={16} strokeWidth={1.75} aria-hidden="true" />
</SelectPrimitive.ScrollDownButton>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
</SelectPrimitive.Root>
{description && <span id={hintId} className={styles.hint}>{description}</span>}
</div>
);
});
이 항목을 쓰는 조합
- 생성형 UI컴포넌트
AI 모델이 OpenUI나 json-render로 쓴 화면을 dsui 컴포넌트로 그려 주는 컴포넌트예요.