formsclient
select
looks like a field, opens a panel of options. works with the keyboard too.
installation
new to blurrow? this sets up your project in one go: the package, the styles and the font. already done it? skip straight to usage. the installation page has the details.
bash
npx @blurrow/ui init›rather own the code? copy the source instead
let the cli copy it, along with anything it needs:
bash
npx @blurrow/ui add selector by hand: drop this file into components/ui and import from there.
components/ui/Select.tsxtsx
1"use client";2
3import React, { useState, useRef, useEffect, useId } from "react";4
5interface SelectOption {6 value: string;7 label: string;8}9
10interface SelectProps {11 options: SelectOption[];12 value?: string;13 onChange?: (value: string) => void;14 placeholder?: string;15 label?: string;16 hint?: string;17 disabled?: boolean;18 fullWidth?: boolean;19 className?: string;20}21
22/* The closed select is a sunken field like Input; the open list is a23 floating panel like DropdownMenu. Keyboard follows the ARIA listbox24 pattern: arrows move, Enter picks, Escape backs out. */25export function Select({26 options,27 value,28 onChange,29 placeholder = "select…",30 label,31 hint,32 disabled = false,33 fullWidth = false,34 className = "",35}: SelectProps) {36 const [open, setOpen] = useState(false);37 // The list stays mounted while it folds away, and only unfolds once painted38 const [mounted, setMounted] = useState(false);39 const [entered, setEntered] = useState(false);40 const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);41 const [active, setActive] = useState(0);42 const ref = useRef<HTMLDivElement>(null);43 const buttonRef = useRef<HTMLButtonElement>(null);44 const listRef = useRef<HTMLDivElement>(null);45 const uid = useId();46
47 const selectedIndex = options.findIndex((o) => o.value === value);48 const selected = options[selectedIndex];49
50 const close = () => {51 setOpen(false);52 setEntered(false);53 if (closeTimer.current) clearTimeout(closeTimer.current);54 closeTimer.current = setTimeout(() => setMounted(false), 200);55 };56
57 useEffect(() => () => {58 if (closeTimer.current) clearTimeout(closeTimer.current);59 }, []);60
61 useEffect(() => {62 function onOutside(e: MouseEvent) {63 if (ref.current && !ref.current.contains(e.target as Node)) {64 close();65 }66 }67 document.addEventListener("mousedown", onOutside);68 return () => document.removeEventListener("mousedown", onOutside);69 }, []);70
71 useEffect(() => {72 if (!open) return;73 listRef.current74 ?.querySelector(`[data-index="${active}"]`)75 ?.scrollIntoView({ block: "nearest" });76 }, [open, active]);77
78 const openList = () => {79 setActive(selectedIndex >= 0 ? selectedIndex : 0);80 if (closeTimer.current) clearTimeout(closeTimer.current);81 setOpen(true);82 setMounted(true);83 requestAnimationFrame(() => requestAnimationFrame(() => setEntered(true)));84 };85
86 const pick = (index: number) => {87 const opt = options[index];88 if (!opt) return;89 onChange?.(opt.value);90 close();91 buttonRef.current?.focus();92 };93
94 const onKeyDown = (e: React.KeyboardEvent) => {95 if (!open) {96 if (["ArrowDown", "ArrowUp", "Enter", " "].includes(e.key)) {97 e.preventDefault();98 openList();99 }100 return;101 }102 switch (e.key) {103 case "ArrowDown":104 e.preventDefault();105 setActive((i) => Math.min(i + 1, options.length - 1));106 break;107 case "ArrowUp":108 e.preventDefault();109 setActive((i) => Math.max(i - 1, 0));110 break;111 case "Home":112 e.preventDefault();113 setActive(0);114 break;115 case "End":116 e.preventDefault();117 setActive(options.length - 1);118 break;119 case "Enter":120 case " ":121 e.preventDefault();122 pick(active);123 break;124 case "Escape":125 e.preventDefault();126 close();127 break;128 case "Tab":129 close();130 break;131 default:132 // Type-ahead: jump to the first option starting with the key133 if (e.key.length === 1) {134 const k = e.key.toLowerCase();135 const hit = options.findIndex((o) => o.label.toLowerCase().startsWith(k));136 if (hit >= 0) setActive(hit);137 }138 }139 };140
141 return (142 <div className={["flex flex-col gap-1.5", fullWidth ? "w-full" : ""].join(" ")}>143 {label && (144 <span145 id={`${uid}-label`}146 className="text-[12.5px] font-medium text-neutral-700 tracking-[-0.005em]"147 >148 {label}149 </span>150 )}151
152 <div153 ref={ref}154 className={["relative", fullWidth ? "w-full" : "inline-block", className].join(" ")}155 >156 <button157 ref={buttonRef}158 type="button"159 disabled={disabled}160 onClick={() => (open ? close() : openList())}161 onKeyDown={onKeyDown}162 aria-haspopup="listbox"163 aria-expanded={open}164 aria-controls={open ? `${uid}-list` : undefined}165 aria-labelledby={label ? `${uid}-label ${uid}-value` : undefined}166 aria-activedescendant={open ? `${uid}-opt-${active}` : undefined}167 className={[168 "group/select relative isolate inline-flex items-center justify-between gap-3 w-full",169 "rounded-xl px-4 py-3 text-[13.5px] font-medium text-neutral-900",170 "field cursor-pointer select-none",171 "focus:outline-none disabled:opacity-40 disabled:pointer-events-none",172 ].join(" ")}173 data-open={open || undefined}174 >175 <span176 id={`${uid}-value`}177 className={[178 "relative z-10 truncate",179 selected ? "text-neutral-900" : "text-neutral-400 font-normal",180 ].join(" ")}181 >182 {selected ? selected.label : placeholder}183 </span>184 <svg185 aria-hidden186 width="14"187 height="14"188 fill="none"189 stroke="currentColor"190 strokeWidth="2"191 viewBox="0 0 24 24"192 className={[193 "relative z-10 shrink-0 transition-[transform,color] duration-300 ease-[var(--ease-spring)]",194 open ? "rotate-180 text-neutral-900" : "text-neutral-400 group-hover/select:text-neutral-700",195 ].join(" ")}196 >197 <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />198 </svg>199
200 {open && (201 <span aria-hidden className="pointer-events-none absolute inset-0 z-5 rounded-xl overflow-hidden">202 <span203 className="block absolute inset-y-0 w-[30%] -left-[30%] animate-input-shimmer"204 style={{205 background:206 "linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.55) 50%, transparent 100%)",207 }}208 />209 </span>210 )}211 </button>212
213 {mounted && (214 <div215 className="absolute left-0 z-50 mt-2 p-1.5 rounded-[16px] origin-top"216 style={{217 minWidth: "100%",218 maxWidth: "min(420px, calc(100vw - 2rem))",219 width: "max-content",220 background: "var(--panel-bezel)",221 boxShadow: "var(--overlay-shadow)",222 /* Unfolds downward out of the field: a clip reveals it top to223 bottom while it fades and settles. The clip's sides reach224 past the panel so its shadow is never cut off. */225 opacity: entered ? 1 : 0,226 transform: entered ? "none" : "translateY(-4px) scale(0.98)",227 clipPath: entered ? "inset(-48px -48px -48px -48px)" : "inset(-48px -48px 55% -48px)",228 transition: entered229 ? "opacity 180ms ease-out, transform 360ms var(--ease-out-soft), clip-path 360ms var(--ease-out-soft)"230 : "opacity 160ms ease-in, transform 200ms ease-in, clip-path 200ms ease-in",231 }}232 >233 <div234 ref={listRef}235 id={`${uid}-list`}236 role="listbox"237 aria-labelledby={label ? `${uid}-label` : undefined}238 className="bg-white rounded-[11px] p-1 flex flex-col gap-0.5 max-h-72 overflow-y-auto scroll-thin"239 style={{ boxShadow: "var(--panel-face-shadow)" }}240 >241 {options.map((opt, i) => {242 const isSelected = opt.value === value;243 const isActive = i === active;244 return (245 <div246 key={opt.value}247 id={`${uid}-opt-${i}`}248 data-index={i}249 role="option"250 aria-selected={isSelected}251 onMouseDown={(e) => e.preventDefault()}252 onClick={() => pick(i)}253 onMouseMove={() => setActive(i)}254 className={[255 "flex items-center justify-between gap-3 w-full px-2.5 py-2 text-[13px] rounded-[8px] cursor-pointer",256 "transition-colors duration-100",257 isSelected ? "font-medium text-neutral-900" : "text-neutral-600",258 isActive ? "bg-neutral-100/80 text-neutral-900" : "",259 ].join(" ")}260 style={{261 animation: `select-option-in 300ms var(--ease-out-soft) ${40 + i * 22}ms both`,262 }}263 >264 <span className="whitespace-nowrap">{opt.label}</span>265 {isSelected && (266 <svg267 aria-hidden268 className="shrink-0 text-neutral-900"269 width="13"270 height="13"271 fill="none"272 stroke="currentColor"273 strokeWidth="2.5"274 viewBox="0 0 24 24"275 >276 <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />277 </svg>278 )}279 </div>280 );281 })}282 </div>283 </div>284 )}285 </div>286
287 {hint && <p className="text-[11.5px] text-neutral-500 mt-0.5">{hint}</p>}288 </div>289 );290}usage
tsx
import { Select } from "@blurrow/ui";tsx
<Select options={options} value={value} onChange={setValue} />examples
with label
sort by
applies to every list
disabled
workspace
props
straight from the source, so they're always up to date.
propsts
interface SelectOption { value: string; label: string;}
interface SelectProps { options: SelectOption[]; value?: string; onChange?: (value: string) => void; placeholder?: string; label?: string; hint?: string; disabled?: boolean; fullWidth?: boolean; className?: string;}