formsclient
radio cards
bigger choices, like a plan. the options sit in one well and a raised thumb springs to the one you pick.
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-cardsor by hand: drop this file into components/ui and import from there.
components/ui/RadioCards.tsxtsx
1"use client";2
3import React, { useEffect, useId, useLayoutEffect, useRef, useState } from "react";4
5
6export interface RadioCardOption {7 value: string;8 label: string;9 description?: string;10 /** Something on the right of the title, like a price */11 aside?: React.ReactNode;12 icon?: React.ReactNode;13 disabled?: boolean;14}15
16interface RadioCardsProps {17 options: RadioCardOption[];18 value?: string;19 defaultValue?: string;20 onChange?: (value: string) => void;21 /** Read out as the group's name */22 label?: string;23 /** Options per row from the small breakpoint up; they stack on phones */24 columns?: 1 | 2 | 3;25 name?: string;26 className?: string;27}28
29const COLS = { 1: "", 2: "sm:grid-cols-2", 3: "sm:grid-cols-3" } as const;30
31// RadioGroup's timings: the leading edge leaves at once, the trailing32// edge follows a beat later on a spring and settles into place33const LEAD = "260ms var(--ease-out-soft)";34const TRAIL = "480ms cubic-bezier(0.34, 1.25, 0.64, 1) 50ms";35
36/* Bigger choices, like a plan or a delivery speed: the segmented control37 from Tabs, grown up. The options sit flat in one sunken well and a38 single raised thumb flows to whichever is chosen, across rows and39 columns: its leading edge goes first and the trailing edge catches40 up, so it stretches toward the new option the way RadioGroup's41 selection does, and you can see which way it went. Each keeps RadioGroup's own control on its right, so the two42 read as one family. One Tab stop, arrows move. */43export function RadioCards({44 options,45 value,46 defaultValue = "",47 onChange,48 label,49 columns = 1,50 name,51 className = "",52}: RadioCardsProps) {53 const uid = useId();54 const [internal, setInternal] = useState(defaultValue);55 const selected = value !== undefined ? value : internal;56 const wellRef = useRef<HTMLDivElement>(null);57 const refs = useRef<(HTMLButtonElement | null)[]>([]);58 const thumbRef = useRef<HTMLSpanElement>(null);59 // Where the thumb last landed, to tell which way the next move goes60 const last = useRef<{ top: number; left: number } | null>(null);61
62 const enabled = options.map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0);63 const selectedIndex = options.findIndex((o) => o.value === selected);64 const tabStop = selectedIndex >= 0 ? selectedIndex : enabled[0];65
66 const selectedRef = useRef(selectedIndex);67
68 /* The thumb is held by its four edges, measured from the well's, and69 written straight onto it, so a move never re-renders anything.70 Heading forward on an axis, the far edge leads and the near one71 trails on a spring; heading back, the other way round. */72 const place = (index: number, animate: boolean) => {73 const thumb = thumbRef.current;74 const el = refs.current[index];75 const well = wellRef.current;76 if (!thumb || !well) return;77 if (!el) {78 thumb.style.opacity = "0";79 last.current = null;80 return;81 }82 const top = el.offsetTop;83 const left = el.offsetLeft;84 const from = animate ? last.current : null;85 const edge = (d: number, near: string, far: string) =>86 d === 0 ? [] : d > 0 ? [`${near} ${TRAIL}`, `${far} ${LEAD}`] : [`${near} ${LEAD}`, `${far} ${TRAIL}`];87 const dy = from ? Math.sign(top - from.top) : 0;88 const dx = from ? Math.sign(left - from.left) : 0;89 thumb.style.transition = [...edge(dy, "top", "bottom"), ...edge(dx, "left", "right")].join(", ") || "none";90 thumb.style.top = `${top}px`;91 thumb.style.left = `${left}px`;92 thumb.style.right = `${well.clientWidth - left - el.offsetWidth}px`;93 thumb.style.bottom = `${well.clientHeight - top - el.offsetHeight}px`;94 thumb.style.opacity = "1";95 last.current = { top, left };96 };97
98 // Before paint, so the thumb is never seen out of place99 useLayoutEffect(() => {100 selectedRef.current = selectedIndex;101 place(selectedIndex, true);102 }, [selectedIndex]);103
104 // Rewrapped text or a new breakpoint moves the options; follow them105 // without animating. An observer reports once as soon as it's attached;106 // that one is skipped, or it would cut short a move already under way.107 useEffect(() => {108 const well = wellRef.current;109 if (!well) return;110 let attached = false;111 const ro = new ResizeObserver(() => {112 if (attached) place(selectedRef.current, false);113 attached = true;114 });115 ro.observe(well);116 return () => ro.disconnect();117 }, []);118
119 const select = (i: number) => {120 const o = options[i];121 if (!o || o.disabled) return;122 setInternal(o.value);123 onChange?.(o.value);124 };125
126 const move = (from: number, step: 1 | -1) => {127 const at = enabled.indexOf(from);128 const next = enabled[(at + step + enabled.length) % enabled.length];129 refs.current[next]?.focus();130 select(next);131 };132
133 return (134 <div135 ref={wellRef}136 role="radiogroup"137 aria-label={label}138 className={["relative grid grid-cols-1 gap-1 rounded-[18px] p-1.5", COLS[columns], className].join(" ")}139 style={{ background: "var(--well)", boxShadow: "var(--well-shadow)" }}140 >141 <span142 ref={thumbRef}143 aria-hidden144 className="pointer-events-none absolute rounded-[13px] opacity-0"145 style={{ background: "var(--face)", boxShadow: "var(--thumb-shadow)" }}146 />147
148 {options.map((o, i) => {149 const on = i === selectedIndex;150 return (151 <button152 key={o.value}153 ref={(el) => {154 refs.current[i] = el;155 }}156 type="button"157 role="radio"158 aria-checked={on}159 aria-labelledby={`${uid}-${i}-label`}160 aria-describedby={o.description ? `${uid}-${i}-desc` : undefined}161 disabled={o.disabled}162 tabIndex={i === tabStop ? 0 : -1}163 onClick={() => select(i)}164 onKeyDown={(e) => {165 if (e.key === "ArrowDown" || e.key === "ArrowRight") {166 e.preventDefault();167 move(i, 1);168 } else if (e.key === "ArrowUp" || e.key === "ArrowLeft") {169 e.preventDefault();170 move(i, -1);171 }172 }}173 className={[174 "group/card relative z-10 flex items-start gap-3 rounded-[13px] px-4 py-3.5 text-left",175 "focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ink",176 "disabled:opacity-40 disabled:cursor-not-allowed",177 on ? "cursor-default" : "cursor-pointer",178 ].join(" ")}179 >180 {o.icon && (181 <span182 aria-hidden183 className={[184 "mt-px flex h-5 w-5 shrink-0 items-center justify-center transition-colors duration-200",185 on ? "text-ink" : "text-ink-faint group-hover/card:text-ink-body",186 ].join(" ")}187 >188 {o.icon}189 </span>190 )}191 <span className="flex min-w-0 flex-1 flex-col gap-0.5">192 <span className="flex items-baseline justify-between gap-3">193 <span194 id={`${uid}-${i}-label`}195 className={[196 "text-[13.5px] font-medium leading-snug transition-colors duration-150",197 on ? "text-ink" : "text-ink-soft group-hover/card:text-ink",198 ].join(" ")}199 >200 {o.label}201 </span>202 {o.aside && (203 <span204 className={[205 "shrink-0 text-[13px] tabular-nums transition-colors duration-150",206 on ? "font-medium text-ink" : "text-ink-muted",207 ].join(" ")}208 >209 {o.aside}210 </span>211 )}212 </span>213 {o.description && (214 <span id={`${uid}-${i}-desc`} className="text-[12px] leading-relaxed text-ink-muted">215 {o.description}216 </span>217 )}218 </span>219 {/* RadioGroup's control: a sunken well, and when chosen a face in220 the inverse material with a dot pressed into it */}221 <span222 aria-hidden223 className={[224 "relative mt-px h-[18px] w-[18px] shrink-0 rounded-full transition-[box-shadow] duration-200",225 on ? "" : "group-hover/card:[box-shadow:var(--field-shadow-hover)]",226 ].join(" ")}227 style={{ background: "var(--field)", boxShadow: "var(--field-shadow)" }}228 >229 <span230 className="absolute inset-0 grid place-items-center rounded-full transition-[opacity,transform] duration-300 ease-[var(--ease-spring)]"231 style={{232 background: "var(--face-inverse)",233 boxShadow: "var(--face-inverse-shadow)",234 opacity: on ? 1 : 0,235 transform: on ? "scale(1)" : "scale(0.4)",236 }}237 >238 <span className="h-1.5 w-1.5 rounded-full bg-ink-inverse" style={{ boxShadow: "var(--dot-shadow)" }} />239 </span>240 </span>241 </button>242 );243 })}244 {name && <input type="hidden" name={name} value={selected} />}245 </div>246 );247}usage
tsx
import { RadioCards } from "@blurrow/ui";tsx
<RadioCards label="plan" options={plans} value={plan} onChange={setPlan} />examples
columns and icons
props
straight from the source, so they're always up to date.
propsts
interface RadioCardOption { value: string; label: string; description?: string; /** Something on the right of the title, like a price */ aside?: React.ReactNode; icon?: React.ReactNode; disabled?: boolean;}
interface RadioCardsProps { options: RadioCardOption[]; value?: string; defaultValue?: string; onChange?: (value: string) => void; /** Read out as the group's name */ label?: string; /** Options per row from the small breakpoint up; they stack on phones */ columns?: 1 | 2 | 3; name?: string; className?: string;}