Select
Select é um componente de formulário para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.
SmoothUI
MIT
forms
O que é
Select é um componente de formulário para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.
Como usar
Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.
Cinco cópias grátis por mês. Sem cartão.
Precisa de
npm i lucide-react motion
Licença
Este componente vem de SmoothUI e é redistribuído sob a licença MIT, que permite uso comercial. O aviso de copyright viaja com o código.
Select
"use client";
import { cn } from "@/lib/utils";
import { Check, ChevronDown } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import {
DURATION_INSTANT,
SPRING_DEFAULT,
SPRING_SNAPPY,
} from "@/components/smoothui/lib/animation";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CHEVRON_ROTATION = 180;
const DROPDOWN_OFFSET = 4;
const STAGGER_DELAY = 0.02;
const ITEM_HOVER_X = 2;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface SelectOptionProps {
/** Whether the option is disabled */
disabled?: boolean;
/** The display label for the option */
label: string;
/** The value of the option */
value: string;
}
export interface SelectGroupOption {
/** Label for the group */
label: string;
/** Options within this group */
options: SelectOptionProps[];
}
export interface SelectProps {
/** Accessible label for the select */
"aria-label"?: string;
/** ID of element that labels this select */
"aria-labelledby"?: string;
/** Additional CSS class names for the trigger */
className?: string;
/** Additional CSS class names for the content dropdown */
contentClassName?: string;
/** The default value (uncontrolled) */
defaultValue?: string;
/** Whether the select is disabled */
disabled?: boolean;
/** Grouped options */
groups?: SelectGroupOption[];
/** The name attribute for form submission */
name?: string;
/** Callback when the value changes */
onValueChange?: (value: string) => void;
/** Flat list of options */
options?: SelectOptionProps[];
/** Placeholder text when no value is selected */
placeholder?: string;
/** Whether the select is required */
required?: boolean;
/** The size of the trigger */
size?: "sm" | "default";
/** The controlled value of the select */
value?: string;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function Select({
value: controlledValue,
defaultValue,
onValueChange,
placeholder = "Select an option",
disabled = false,
required = false,
name,
options,
groups,
className,
contentClassName,
size = "default",
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledBy,
}: SelectProps) {
const shouldReduceMotion = useReducedMotion();
const [isOpen, setIsOpen] = useState(false);
const [internalValue, setInternalValue] = useState(defaultValue ?? "");
const [focusedIndex, setFocusedIndex] = useState(-1);
const [position, setPosition] = useState({ left: 0, top: 0, width: 0 });
const triggerRef = useRef<HTMLButtonElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const portalRef = useRef<HTMLDivElement>(null);
const selectedValue =
controlledValue === undefined ? internalValue : controlledValue;
// Flatten all options for keyboard navigation
const allOptions: SelectOptionProps[] = (() => {
const flat: SelectOptionProps[] = [];
if (options) {
for (const opt of options) {
flat.push(opt);
}
}
if (groups) {
for (const group of groups) {
for (const opt of group.options) {
flat.push(opt);
}
}
}
return flat;
})();
const selectedLabel = allOptions.find(
(opt) => opt.value === selectedValue
)?.label;
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
const handleSelect = useCallback(
(opt: SelectOptionProps) => {
if (opt.disabled) {
return;
}
if (controlledValue === undefined) {
setInternalValue(opt.value);
}
onValueChange?.(opt.value);
setIsOpen(false);
setFocusedIndex(-1);
triggerRef.current?.focus();
},
[controlledValue, onValueChange]
);
const handleToggle = useCallback(() => {
if (disabled) {
return;
}
if (!isOpen && triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
setPosition({
left: rect.left,
top: rect.bottom + DROPDOWN_OFFSET,
width: rect.width,
});
}
setIsOpen((prev) => !prev);
setFocusedIndex(-1);
}, [disabled, isOpen]);
// ---------------------------------------------------------------------------
// Position updates on scroll/resize
// ---------------------------------------------------------------------------
useEffect(() => {
if (!(isOpen && triggerRef.current)) {
return;
}
const updatePosition = () => {
if (triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
setPosition({
left: rect.left,
top: rect.bottom + DROPDOWN_OFFSET,
width: rect.width,
});
}
};
window.addEventListener("scroll", updatePosition, true);
window.addEventListener("resize", updatePosition);
return () => {
window.removeEventListener("scroll", updatePosition, true);
window.removeEventListener("resize", updatePosition);
};
}, [isOpen]);
// ---------------------------------------------------------------------------
// Click outside to close
// ---------------------------------------------------------------------------
useEffect(() => {
if (!isOpen) {
return;
}
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
if (
wrapperRef.current &&
!wrapperRef.current.contains(target) &&
portalRef.current &&
!portalRef.current.contains(target)
) {
setIsOpen(false);
setFocusedIndex(-1);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen]);
// ---------------------------------------------------------------------------
// Keyboard navigation
// ---------------------------------------------------------------------------
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!isOpen) {
if (
(event.key === "Enter" || event.key === " ") &&
document.activeElement === triggerRef.current
) {
event.preventDefault();
handleToggle();
}
return;
}
if (event.key === "Escape") {
setIsOpen(false);
setFocusedIndex(-1);
triggerRef.current?.focus();
} else if (event.key === "ArrowDown") {
event.preventDefault();
setFocusedIndex((prev) =>
prev < allOptions.length - 1 ? prev + 1 : 0
);
} else if (event.key === "ArrowUp") {
event.preventDefault();
setFocusedIndex((prev) =>
prev > 0 ? prev - 1 : allOptions.length - 1
);
} else if (event.key === "Enter" && focusedIndex >= 0) {
event.preventDefault();
const opt = allOptions[focusedIndex];
if (opt) {
handleSelect(opt);
}
} else if (event.key === "Home") {
event.preventDefault();
setFocusedIndex(0);
} else if (event.key === "End") {
event.preventDefault();
setFocusedIndex(allOptions.length - 1);
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
// biome-ignore lint/correctness/useExhaustiveDependencies: handlers stable via closure
}, [isOpen, allOptions, focusedIndex, handleSelect, handleToggle]);
// ---------------------------------------------------------------------------
// Render helpers
// ---------------------------------------------------------------------------
/** Render a single option item with stagger animation */
const renderItem = (opt: SelectOptionProps, itemIndex: number) => {
const isSelected = opt.value === selectedValue;
const isFocused = itemIndex === focusedIndex;
return (
<motion.div
animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1, x: 0 }}
exit={
shouldReduceMotion
? { opacity: 0, transition: { duration: 0 } }
: { opacity: 0, x: -8 }
}
initial={shouldReduceMotion ? { opacity: 1 } : { opacity: 0, x: -8 }}
key={opt.value}
transition={
shouldReduceMotion
? DURATION_INSTANT
: {
...SPRING_SNAPPY,
delay: itemIndex * STAGGER_DELAY,
}
}
whileHover={shouldReduceMotion ? {} : { x: ITEM_HOVER_X }}
>
<button
aria-selected={isSelected}
className={cn(
"relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-left text-sm outline-hidden",
"transition-colors",
opt.disabled
? "pointer-events-none opacity-50"
: "hover:bg-accent hover:text-white",
isFocused && "bg-accent text-white",
isSelected && "font-medium"
)}
disabled={opt.disabled}
onClick={() => handleSelect(opt)}
onMouseEnter={() => setFocusedIndex(itemIndex)}
role="option"
type="button"
>
<span className="flex-1 truncate">{opt.label}</span>
{/* Animated checkmark */}
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<AnimatePresence>
{isSelected && (
<motion.span
animate={shouldReduceMotion ? {} : { opacity: 1, scale: 1 }}
exit={
shouldReduceMotion
? { opacity: 0, transition: { duration: 0 } }
: { opacity: 0, scale: 0 }
}
initial={shouldReduceMotion ? {} : { opacity: 0, scale: 0 }}
transition={
shouldReduceMotion
? DURATION_INSTANT
: {
damping: 20,
duration: 0.2,
stiffness: 300,
type: "spring" as const,
}
}
>
<Check className="size-4" />
</motion.span>
)}
</AnimatePresence>
</span>
</button>
</motion.div>
);
};
// Global index counter for stagger across groups
let globalIndex = 0;
// ---------------------------------------------------------------------------
// Dropdown content (portalled)
// ---------------------------------------------------------------------------
const dropdownContent = (
<AnimatePresence>
{isOpen ? (
<div ref={portalRef}>
<motion.div
animate={
shouldReduceMotion
? { opacity: 1 }
: { opacity: 1, scale: 1, y: 0 }
}
className={cn(
"fixed z-50 origin-top overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md",
contentClassName
)}
exit={
shouldReduceMotion
? { opacity: 0, transition: { duration: 0 } }
: {
opacity: 0,
scale: 0.95,
transition: { duration: 0.15 },
y: -4,
}
}
initial={
shouldReduceMotion
? { opacity: 1 }
: { opacity: 0, scale: 0.95, y: -4 }
}
role="listbox"
style={{
left: `${position.left}px`,
top: `${position.top}px`,
width: `${position.width}px`,
}}
transition={shouldReduceMotion ? DURATION_INSTANT : SPRING_DEFAULT}
>
<div className="max-h-60 overflow-y-auto p-1">
{/* Flat options */}
{options &&
options.length > 0 &&
(() => {
const items = options.map((opt) => {
const idx = globalIndex;
globalIndex += 1;
return renderItem(opt, idx);
});
return items;
})()}
{/* Grouped options */}
{groups
? groups.map((group, groupIdx) => {
const groupItems = group.options.map((opt) => {
const idx = globalIndex;
globalIndex += 1;
return renderItem(opt, idx);
});
return (
<div key={group.label}>
{groupIdx > 0 && (
<div className="pointer-events-none -mx-1 my-1 h-px bg-border" />
)}
<div className="px-2 py-1.5 text-muted-foreground text-xs">
{group.label}
</div>
{groupItems}
</div>
);
})
: null}
</div>
</motion.div>
</div>
) : null}
</AnimatePresence>
);
// ---------------------------------------------------------------------------
// Main render
// ---------------------------------------------------------------------------
return (
<>
<div className="relative inline-block w-full" ref={wrapperRef}>
{/* Hidden native input for form submission */}
{name ? (
<input
aria-hidden="true"
name={name}
required={required}
tabIndex={-1}
type="hidden"
value={selectedValue}
/>
) : null}
<button
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-required={required || undefined}
className={cn(
"flex w-full cursor-pointer items-center justify-between gap-2 whitespace-nowrap rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground",
size === "default" ? "h-9" : "h-8",
className
)}
data-placeholder={!selectedLabel || undefined}
disabled={disabled}
onClick={handleToggle}
ref={triggerRef}
role="combobox"
type="button"
>
<span
className={cn(
"line-clamp-1 flex items-center gap-2 text-left",
!selectedLabel && "text-muted-foreground"
)}
>
{selectedLabel ?? placeholder}
</span>
{/* Animated chevron */}
<motion.div
animate={{ rotate: isOpen ? CHEVRON_ROTATION : 0 }}
className="shrink-0"
transition={
shouldReduceMotion
? DURATION_INSTANT
: { bounce: 0.05, duration: 0.25, type: "spring" as const }
}
>
<ChevronDown className="size-4 opacity-50" />
</motion.div>
</button>
</div>
{typeof window === "undefined"
? null
: createPortal(dropdownContent, document.body)}
</>
);
}