formsclient
radio group
pick one option from a few. the dot slides over to whatever you choose.
weeklyevery monday at 9am
monthlyfirst day of the month
neveri'll check manually
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 radio-groupor by hand: drop this file into components/ui and import from there.
components/ui/Radio.tsxtsx
1"use client";2
3import React, { useEffect, useId, useLayoutEffect, useRef, useState } from "react";4
5interface RadioOption {6 value: string;7 label: string;8 description?: string;9 disabled?: boolean;10}11
12interface RadioGroupProps {13 options: RadioOption[];14 value?: string;15 defaultValue?: string;16 onChange?: (value: string) => void;17 name?: string;18 className?: string;19}20
21/* Every radio is a sunken field; the selection is one dark face that22 flows between them. Its leading edge leaves first and the trailing edge23 catches up on a spring, so it stretches across the gap like a drop of24 liquid and settles into the new well. One Tab stop for the group,25 arrows move between options (ARIA radio group pattern). */26interface Blob {27 top: number;28 bottom: number;29 left: number;30 size: number;31 dir: -1 | 0 | 1;32}33
34const LEAD = "260ms var(--ease-out-soft)";35const TRAIL = "480ms cubic-bezier(0.34, 1.25, 0.64, 1) 50ms";36export function RadioGroup({37 options,38 value,39 defaultValue = "",40 onChange,41 name,42 className = "",43}: RadioGroupProps) {44 const [internal, setInternal] = useState(defaultValue);45 const selected = value !== undefined ? value : internal;46 const uid = useId();47 const refs = useRef<(HTMLButtonElement | null)[]>([]);48 const groupRef = useRef<HTMLDivElement>(null);49 const prevIndex = useRef(-1);50 const [blob, setBlob] = useState<Blob | null>(null);51
52 const select = (val: string, disabled?: boolean) => {53 if (disabled) return;54 setInternal(val);55 onChange?.(val);56 };57
58 const enabled = options.map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0);59 const selectedIndex = options.findIndex((o) => o.value === selected);60 const tabStop = selectedIndex >= 0 ? selectedIndex : enabled[0];61
62 const measure = (dir: Blob["dir"]) => {63 const group = groupRef.current;64 const el = refs.current[prevIndex.current];65 if (!group || !el) return setBlob(null);66 setBlob({67 top: el.offsetTop,68 bottom: group.clientHeight - el.offsetTop - el.offsetHeight,69 left: el.offsetLeft,70 size: el.offsetWidth,71 dir,72 });73 };74
75 useLayoutEffect(() => {76 const prev = prevIndex.current;77 prevIndex.current = selectedIndex;78 measure(prev < 0 || selectedIndex < 0 ? 0 : selectedIndex > prev ? 1 : selectedIndex < prev ? -1 : 0);79 // eslint-disable-next-line react-hooks/exhaustive-deps80 }, [selectedIndex]);81
82 // Rewrapped descriptions move the wells; follow them without animating.83 // An observer reports once on attach — that one is skipped, or it would84 // cut short a move already under way.85 useEffect(() => {86 const group = groupRef.current;87 if (!group) return;88 let attached = false;89 const ro = new ResizeObserver(() => {90 if (attached) measure(0);91 attached = true;92 });93 ro.observe(group);94 return () => ro.disconnect();95 // eslint-disable-next-line react-hooks/exhaustive-deps96 }, []);97
98 const move = (from: number, step: 1 | -1) => {99 const at = enabled.indexOf(from);100 const next = enabled[(at + step + enabled.length) % enabled.length];101 refs.current[next]?.focus();102 select(options[next].value);103 };104
105 return (106 <div107 ref={groupRef}108 role="radiogroup"109 aria-label={name}110 className={["relative flex flex-col gap-1", className].join(" ")}111 >112 {options.map((opt, i) => {113 const active = selected === opt.value;114 return (115 <div116 key={opt.value}117 onClick={() => select(opt.value, opt.disabled)}118 className={[119 "flex items-center justify-between gap-4 select-none py-1.5",120 opt.disabled ? "opacity-40 pointer-events-none" : "cursor-pointer group/radio",121 ].join(" ")}122 >123 <div className="flex flex-col gap-0.5 min-w-0">124 <span id={`${uid}-${i}-label`} className="text-[13px] font-medium text-neutral-900 leading-snug">125 {opt.label}126 </span>127 {opt.description && (128 <span id={`${uid}-${i}-desc`} className="text-[12px] text-neutral-500 leading-relaxed">129 {opt.description}130 </span>131 )}132 </div>133
134 {/* Clicks bubble to the row, so the control itself has no onClick */}135 <button136 ref={(el) => {137 refs.current[i] = el;138 }}139 type="button"140 role="radio"141 aria-checked={active}142 aria-labelledby={`${uid}-${i}-label`}143 aria-describedby={opt.description ? `${uid}-${i}-desc` : undefined}144 disabled={opt.disabled}145 tabIndex={i === tabStop ? 0 : -1}146 onKeyDown={(e) => {147 if (e.key === "ArrowDown" || e.key === "ArrowRight") {148 e.preventDefault();149 move(i, 1);150 } else if (e.key === "ArrowUp" || e.key === "ArrowLeft") {151 e.preventDefault();152 move(i, -1);153 }154 }}155 className={[156 "shrink-0 w-[22px] h-[22px] rounded-full cursor-pointer",157 "transition-[box-shadow] duration-200 ease-out",158 "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900",159 active ? "" : "group-hover/radio:[box-shadow:var(--field-shadow-hover)]",160 ].join(" ")}161 style={{ background: "var(--field)", boxShadow: "var(--field-shadow)" }}162 />163 </div>164 );165 })}166
167 {blob && (168 <>169 <span170 aria-hidden171 className="pointer-events-none absolute rounded-full"172 style={{173 top: blob.top,174 bottom: blob.bottom,175 left: blob.left,176 width: blob.size,177 background: "var(--face-dark)",178 boxShadow: "var(--face-dark-shadow)",179 transition:180 blob.dir === 0181 ? "none"182 : blob.dir > 0183 ? `top ${TRAIL}, bottom ${LEAD}`184 : `top ${LEAD}, bottom ${TRAIL}`,185 }}186 />187 {/* The dot rides the trailing edge's spring, arriving as it settles */}188 <span189 aria-hidden190 className="pointer-events-none absolute w-2 h-2 rounded-full bg-white"191 style={{192 top: blob.top + (blob.size - 8) / 2,193 left: blob.left + (blob.size - 8) / 2,194 boxShadow: "0 1px 1px rgba(0,0,0,0.3)",195 transition: blob.dir === 0 ? "none" : `top ${TRAIL}`,196 }}197 />198 </>199 )}200 </div>201 );202}usage
tsx
import { RadioGroup } from "@blurrow/ui";tsx
<RadioGroup options={options} value={value} onChange={setValue} />examples
disabled option
free
pro
teamcoming soon
props
straight from the source, so they're always up to date.
propsts
interface RadioOption { value: string; label: string; description?: string; disabled?: boolean;}
interface RadioGroupProps { options: RadioOption[]; value?: string; defaultValue?: string; onChange?: (value: string) => void; name?: string; className?: string;}