formsclient
file upload
a well you drop files into. each one lands in a list with its size, a progress bar while it uploads and a key to remove it.
attachments
drop files hereup to 5 files, 10 mb each
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 file-uploador by hand: drop these files into components/ui and import from there. file upload leans on Button.tsx, CloseButton.tsx and FieldMessage.tsx, so those come along too.
components/ui/FileUpload.tsxtsx
1"use client";2
3import React, { useEffect, useId, useRef, useState } from "react";4import { Button } from "./Button";5import { CloseButton } from "./CloseButton";6import { FieldLabel, FieldMessage } from "./FieldMessage";7
8export interface UploadItem {9 id: string;10 file: File;11 /** 0–100 while uploading */12 progress: number;13 status: "ready" | "uploading" | "done" | "error";14 error?: string;15}16
17interface FileUploadProps {18 /** Same as the input's accept: "image/*", ".pdf,.docx" */19 accept?: string;20 multiple?: boolean;21 /** In bytes; bigger files land with an error instead of uploading */22 maxSize?: number;23 maxFiles?: number;24 /** Upload each file as it lands. Call onProgress with 0–100; throw to fail it */25 upload?: (file: File, onProgress: (pct: number) => void) => Promise<void>;26 /** Every change to the list: added, progressed, finished, removed */27 onChange?: (items: UploadItem[]) => void;28 label?: string;29 hint?: string;30 /** The words in the empty tray */31 prompt?: string;32 disabled?: boolean;33 className?: string;34}35
36export function formatBytes(n: number) {37 if (n < 1024) return `${n} b`;38 const units = ["kb", "mb", "gb"];39 let i = -1;40 do {41 n /= 1024;42 i++;43 } while (n >= 1024 && i < units.length - 1);44 return `${n.toFixed(n < 10 ? 1 : 0)} ${units[i]}`;45}46
47/* Matches a file against an accept string the way the browser's picker does */48function accepts(file: File, accept?: string) {49 if (!accept) return true;50 return accept.split(",").some((rule) => {51 const r = rule.trim().toLowerCase();52 if (r.startsWith(".")) return file.name.toLowerCase().endsWith(r);53 if (r.endsWith("/*")) return file.type.startsWith(r.slice(0, -1));54 return file.type === r;55 });56}57
58let seq = 0;59
60/* Something you fill, so it's built as a field: a wide sunken tray. Drag61 files over it and it lifts flush and draws its border, the way an62 Input does when it takes focus. What you drop lands inside it as63 raised chips; each has a groove along its foot that fills while it64 uploads, then fades once it's done. */65export function FileUpload({66 accept,67 multiple = true,68 maxSize,69 maxFiles,70 upload,71 onChange,72 label,73 hint,74 prompt,75 disabled = false,76 className = "",77}: FileUploadProps) {78 const uid = useId();79 const inputRef = useRef<HTMLInputElement>(null);80 const [items, setItems] = useState<UploadItem[]>([]);81 const [previews, setPreviews] = useState<Record<string, string>>({});82 const [over, setOver] = useState(false);83 const [notice, setNotice] = useState<string | null>(null);84 const depth = useRef(0);85
86 // Report every change to the list, after it renders87 const report = useRef(onChange);88 useEffect(() => {89 report.current = onChange;90 });91 useEffect(() => {92 report.current?.(items);93 }, [items]);94
95 // Image previews are object URLs; give them back when they're gone96 const urls = useRef(previews);97 useEffect(() => {98 urls.current = previews;99 });100 useEffect(() => () => Object.values(urls.current).forEach(URL.revokeObjectURL), []);101
102 const patch = (id: string, p: Partial<UploadItem>) =>103 setItems((list) => list.map((it) => (it.id === id ? { ...it, ...p } : it)));104
105 const start = (item: UploadItem) => {106 if (!upload) return;107 patch(item.id, { status: "uploading", progress: 0 });108 upload(item.file, (pct) => patch(item.id, { progress: Math.max(0, Math.min(100, pct)) }))109 .then(() => patch(item.id, { status: "done", progress: 100 }))110 .catch((err: unknown) =>111 patch(item.id, { status: "error", error: err instanceof Error ? err.message : "upload failed" }),112 );113 };114
115 const add = (files: FileList | File[]) => {116 if (disabled) return;117 let list = Array.from(files);118 if (!multiple) list = list.slice(0, 1);119 const room = maxFiles ? Math.max(0, maxFiles - (multiple ? items.length : 0)) : Infinity;120 setNotice(list.length > room ? `only ${maxFiles} file${maxFiles === 1 ? "" : "s"} can go in` : null);121 const next: UploadItem[] = list.slice(0, room).map((file) => {122 const id = `${uid}-${++seq}`;123 if (!accepts(file, accept)) return { id, file, progress: 0, status: "error", error: "not a type this takes" };124 if (maxSize && file.size > maxSize)125 return { id, file, progress: 0, status: "error", error: `over ${formatBytes(maxSize)}` };126 return { id, file, progress: 0, status: "ready" };127 });128 const shots: Record<string, string> = {};129 next.forEach((it) => {130 if (it.file.type.startsWith("image/") && it.status !== "error") shots[it.id] = URL.createObjectURL(it.file);131 });132 if (!multiple) Object.values(previews).forEach(URL.revokeObjectURL);133 setPreviews((p) => (multiple ? { ...p, ...shots } : shots));134 setItems((cur) => (multiple ? [...cur, ...next] : next));135 next.filter((it) => it.status === "ready").forEach(start);136 };137
138 const remove = (id: string) => {139 if (previews[id]) URL.revokeObjectURL(previews[id]);140 setPreviews((p) => {141 const rest = { ...p };142 delete rest[id];143 return rest;144 });145 setItems((list) => list.filter((it) => it.id !== id));146 };147
148 const browse = () => !disabled && inputRef.current?.click();149 const full = !!maxFiles && multiple && items.length >= maxFiles;150 const empty = items.length === 0;151
152 return (153 <div className={["group/field flex w-full flex-col gap-1.5", className].join(" ")}>154 {label && <FieldLabel id={`${uid}-label`}>{label}</FieldLabel>}155
156 {/* The tray: a field, so data-open gives it Input's focused well */}157 <div158 role="group"159 aria-labelledby={label ? `${uid}-label` : undefined}160 aria-describedby={hint ? `${uid}-hint` : undefined}161 data-open={over || undefined}162 onDragEnter={(e) => {163 e.preventDefault();164 if (disabled) return;165 depth.current++;166 setOver(true);167 }}168 onDragOver={(e) => e.preventDefault()}169 onDragLeave={() => {170 // dragleave fires for every child crossed; only the last one counts171 if (--depth.current <= 0) setOver(false);172 }}173 onDrop={(e) => {174 e.preventDefault();175 depth.current = 0;176 setOver(false);177 add(e.dataTransfer.files);178 }}179 className={[180 "field relative flex flex-col rounded-[18px] p-2",181 disabled ? "pointer-events-none opacity-40" : "",182 ].join(" ")}183 >184 {empty ? (185 <div186 className="flex cursor-pointer flex-col items-center gap-3.5 px-6 py-9 text-center"187 onClick={(e) => e.target === e.currentTarget && browse()}188 >189 <span className="flex flex-col gap-1">190 <span className="text-[14px] font-medium text-ink tracking-[-0.01em]">191 {over ? "let go to drop them in" : prompt ?? (multiple ? "drop files here" : "drop a file here")}192 </span>193 {hint && (194 <span id={`${uid}-hint`} className="text-[12px] text-ink-muted">195 {hint}196 </span>197 )}198 </span>199 <Button variant="secondary" size="sm" onClick={browse} disabled={disabled}>200 {multiple ? "choose files" : "choose a file"}201 </Button>202 </div>203 ) : (204 <>205 <ul aria-label="files" className="flex flex-col gap-1.5">206 {items.map((it) => (207 <FileChip key={it.id} item={it} preview={previews[it.id]} onRemove={() => remove(it.id)} />208 ))}209 </ul>210 <div className="flex items-center justify-between gap-3 pl-2.5 pr-0.5 pt-2.5 pb-0.5">211 <span id={`${uid}-hint`} className="truncate text-[12px] text-ink-muted">212 {over ? "let go to add them" : hint ?? `${items.length} file${items.length === 1 ? "" : "s"}`}213 </span>214 {!full && (215 <Button variant="secondary" size="sm" onClick={browse} disabled={disabled}>216 {multiple ? "add more" : "replace"}217 </Button>218 )}219 </div>220 </>221 )}222
223 <input224 ref={inputRef}225 type="file"226 accept={accept}227 multiple={multiple}228 disabled={disabled}229 tabIndex={-1}230 aria-hidden231 className="sr-only"232 onChange={(e) => {233 if (e.target.files) add(e.target.files);234 e.target.value = ""; // so picking the same file again still fires235 }}236 />237 </div>238
239 {notice && <FieldMessage error={notice} />}240 </div>241 );242}243
244/* A raised chip sitting in the tray. The groove along its foot is a245 Progress track in miniature; it fills, then fades once the file is up. */246function FileChip({ item, preview, onRemove }: { item: UploadItem; preview?: string; onRemove: () => void }) {247 const failed = item.status === "error";248 const meta = failed249 ? item.error250 : item.status === "uploading"251 ? `${formatBytes(item.file.size)} · ${Math.round(item.progress)}%`252 : item.status === "done"253 ? `${formatBytes(item.file.size)} · uploaded`254 : formatBytes(item.file.size);255 return (256 <li257 className="relative flex items-center gap-3 overflow-hidden rounded-[12px] pt-2.5 pb-3.5 pl-2.5 pr-2"258 style={{259 background: "var(--face)",260 boxShadow: "var(--thumb-shadow)",261 animation: "select-option-in 320ms var(--ease-out-soft) both",262 }}263 >264 {preview && (265 // eslint-disable-next-line @next/next/no-img-element -- a local object URL, nothing to optimise266 <img267 src={preview}268 alt=""269 className="h-9 w-9 shrink-0 rounded-[8px] object-cover"270 style={{ boxShadow: "inset 0 0 0 1px var(--hairline), var(--key-shadow)" }}271 />272 )}273 <span className="flex min-w-0 flex-1 flex-col gap-0.5">274 <span className="truncate text-[13px] font-medium text-ink">{item.file.name}</span>275 <span276 className={[277 "flex items-center gap-1 truncate text-[11.5px] tabular-nums",278 failed ? "text-danger" : "text-ink-muted",279 ].join(" ")}280 >281 {item.status === "done" && (282 <svg aria-hidden width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2.75" viewBox="0 0 24 24" className="text-success">283 <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />284 </svg>285 )}286 {meta}287 </span>288 </span>289 <CloseButton size="sm" label={`remove ${item.file.name}`} onClick={onRemove} />290
291 {/* The groove along the chip's foot */}292 <span293 aria-hidden={item.status !== "uploading"}294 role={item.status === "uploading" ? "progressbar" : undefined}295 aria-label={item.status === "uploading" ? `uploading ${item.file.name}` : undefined}296 aria-valuenow={item.status === "uploading" ? Math.round(item.progress) : undefined}297 aria-valuemin={0}298 aria-valuemax={100}299 className="absolute inset-x-2.5 bottom-[6px] h-[3px] rounded-full transition-opacity duration-500"300 style={{301 background: "var(--track)",302 boxShadow: "var(--track-shadow)",303 opacity: item.status === "uploading" ? 1 : 0,304 transitionDelay: item.status === "done" ? "400ms" : "0ms",305 }}306 >307 <span308 className="block h-full rounded-full"309 style={{310 width: `${item.progress}%`,311 background: "var(--face-inverse)",312 transition: "width 300ms var(--ease-out-soft)",313 }}314 />315 </span>316 </li>317 );318}components/ui/Button.tsxtsx
1"use client";2
3import React, { useState, useRef, useCallback } from "react";4
5type ButtonVariant = "primary" | "secondary" | "ghost" | "outline";6type ButtonSize = "sm" | "md" | "lg" | "icon";7
8interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {9 variant?: ButtonVariant;10 size?: ButtonSize;11 children: React.ReactNode;12 fullWidth?: boolean;13 /** Surface the button sits on. On dark cards, secondary/outline switch to a14 dark face so they don't glare against the card. */15 tone?: "light" | "dark";16 /** Equal width and height, for icon-only layered buttons. */17 square?: boolean;18}19
20/* Layered buttons are a raised face seated in a machined bezel. The bezel21 never moves; the face lifts on hover and sinks flush when pressed. */22const shellConfig: Record<23 string,24 {25 bezel: string;26 bezelShadow: string;27 face: string;28 faceShadow: string;29 faceHoverShadow: string;30 facePressedShadow: string;31 text: string;32 textShadow: string;33 fill: string;34 }35> = {36 primary: {37 bezel: "var(--bezel-inverse)",38 bezelShadow: "var(--bezel-inverse-shadow)",39 face: "var(--face-inverse)",40 faceShadow: "var(--face-inverse-shadow)",41 faceHoverShadow: "var(--face-inverse-shadow-hover)",42 facePressedShadow: "var(--face-inverse-pressed)",43 text: "text-ink-inverse",44 textShadow: "var(--emboss-inverse-deep)",45 fill: "bg-ink-inverse/[0.05]",46 },47 secondary: {48 bezel: "var(--bezel)",49 bezelShadow: "var(--bezel-shadow)",50 face: "var(--face)",51 faceShadow: "var(--face-shadow)",52 faceHoverShadow: "var(--face-shadow-hover)",53 facePressedShadow: "var(--face-pressed)",54 text: "text-ink-soft",55 textShadow: "var(--emboss)",56 fill: "bg-ink/[0.025]",57 },58 outline: {59 bezel: "var(--outline-bezel)",60 bezelShadow: "var(--outline-bezel-shadow)",61 face: "var(--face)",62 faceShadow: "var(--outline-face-shadow)",63 faceHoverShadow: "var(--outline-face-shadow-hover)",64 facePressedShadow: "var(--face-pressed)",65 text: "text-ink-label",66 textShadow: "var(--emboss)",67 fill: "bg-ink/[0.025]",68 },69};70
71/* Secondary on a dark surface: a raised face a few steps lighter than the72 card — present, but in the same family of tones. It's the mid material,73 the same one secondary keys wear everywhere in the dark theme. */74const darkSecondary = {75 bezel: "var(--bezel-mid)",76 bezelShadow: "var(--bezel-mid-shadow)",77 face: "var(--face-mid)",78 faceShadow: "var(--face-mid-shadow)",79 faceHoverShadow: "var(--face-mid-shadow-hover)",80 facePressedShadow: "var(--face-dark-pressed)",81 text: "text-neutral-100",82 textShadow: "var(--emboss-inverse)",83 fill: "bg-white/[0.04]",84};85
86/* Flat buttons (ghost, icon) are a single face without a bezel. */87const flatConfig: Record<88 ButtonVariant,89 {90 bg: string;91 background?: string;92 fill: string;93 shadow: string;94 hoverShadow: string;95 pressedShadow: string;96 }97> = {98 primary: {99 bg: "text-ink-inverse",100 background: "var(--face-inverse)",101 fill: "bg-ink-inverse/[0.08]",102 shadow: "var(--face-inverse-shadow)",103 hoverShadow: "var(--face-inverse-float-shadow)",104 pressedShadow: "var(--face-inverse-pressed)",105 },106 secondary: {107 bg: "text-ink-soft",108 background: "var(--face)",109 fill: "bg-ink/[0.035]",110 shadow: "var(--face-shadow)",111 hoverShadow: "var(--face-float-shadow)",112 pressedShadow: "var(--face-pressed)",113 },114 ghost: {115 bg: "bg-transparent text-ink-muted hover:text-ink-soft",116 fill: "bg-ink/[0.05]",117 shadow: "none",118 hoverShadow: "var(--ghost-shadow-hover)",119 pressedShadow: "var(--ghost-shadow-pressed)",120 },121 outline: {122 bg: "bg-transparent text-ink-label",123 fill: "bg-ink/[0.035]",124 shadow: "var(--outline-shadow)",125 hoverShadow: "var(--outline-shadow-hover)",126 pressedShadow: "var(--outline-shadow-pressed)",127 },128};129
130/* Bezel radius minus bezel padding = face radius, so the curves stay concentric. */131const shellRadius: Record<ButtonSize, string> = {132 sm: "rounded-[12px] p-[3px]",133 md: "rounded-[13px] p-[3px]",134 lg: "rounded-[15px] p-[3.5px]",135 icon: "",136};137
138const faceRadius: Record<ButtonSize, string> = {139 sm: "rounded-[9px]",140 md: "rounded-[10px]",141 lg: "rounded-[11.5px]",142 icon: "",143};144
145const innerPadding: Record<ButtonSize, string> = {146 sm: "px-4 py-2 text-[13px]",147 md: "px-5 py-2.5 text-sm",148 lg: "px-7 py-3 text-[15px]",149 icon: "w-7 h-7 p-0 text-[13px]",150};151
152const squarePadding: Record<ButtonSize, string> = {153 sm: "w-9 h-9 p-0 text-[13px]",154 md: "w-10 h-10 p-0 text-sm",155 lg: "w-12 h-12 p-0 text-[15px]",156 icon: "",157};158
159const flatSizeStyles: Record<ButtonSize, string> = {160 sm: "px-4 py-2.5 text-[13px] rounded-[10px]",161 md: "px-5 py-3 text-sm rounded-[10px]",162 lg: "px-7 py-3.5 text-[15px] rounded-xl",163 icon: "w-8 h-8 p-0 rounded-[10px] text-[13px]",164};165
166export function Button({167 variant = "primary",168 size = "md",169 fullWidth = false,170 tone = "light",171 square = false,172 className = "",173 children,174 disabled,175 onClick,176 onPointerDown,177 onPointerUp,178 onPointerLeave,179 onPointerCancel,180 onPointerEnter,181 style,182 ...rest183}: ButtonProps) {184 const [pressed, setPressed] = useState(false);185 const [hovered, setHovered] = useState(false);186 const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);187
188 /* The press state is the button's own; handlers passed in run alongside189 it rather than replacing it */190 const handlePointerDown = useCallback(191 (e: React.PointerEvent<HTMLButtonElement>) => {192 if (timerRef.current) clearTimeout(timerRef.current);193 setPressed(true);194 onPointerDown?.(e);195 },196 [onPointerDown],197 );198
199 const handlePointerUp = useCallback(200 (e: React.PointerEvent<HTMLButtonElement>) => {201 timerRef.current = setTimeout(() => setPressed(false), 150);202 onPointerUp?.(e);203 },204 [onPointerUp],205 );206
207 const handlePointerCancel = useCallback(208 (e: React.PointerEvent<HTMLButtonElement>) => {209 timerRef.current = setTimeout(() => setPressed(false), 150);210 onPointerCancel?.(e);211 },212 [onPointerCancel],213 );214
215 const handlePointerLeave = useCallback(216 (e: React.PointerEvent<HTMLButtonElement>) => {217 setHovered(false);218 if (timerRef.current) clearTimeout(timerRef.current);219 timerRef.current = setTimeout(() => setPressed(false), 150);220 onPointerLeave?.(e);221 },222 [onPointerLeave],223 );224
225 const layered = variant !== "ghost" && size !== "icon";226
227 const handlers = {228 onClick,229 onPointerDown: handlePointerDown,230 onPointerUp: handlePointerUp,231 onPointerLeave: handlePointerLeave,232 onPointerCancel: handlePointerCancel,233 onPointerEnter: (e: React.PointerEvent<HTMLButtonElement>) => {234 setHovered(true);235 onPointerEnter?.(e);236 },237 };238
239 if (layered) {240 const cfg =241 tone === "dark" && (variant === "secondary" || variant === "outline")242 ? darkSecondary243 : shellConfig[variant];244 const faceShadow = pressed245 ? cfg.facePressedShadow246 : hovered247 ? cfg.faceHoverShadow248 : cfg.faceShadow;249
250 return (251 <button252 data-btn253 className={[254 "group/btn relative inline-flex cursor-pointer select-none whitespace-nowrap",255 shellRadius[size],256 "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink",257 "disabled:opacity-40 disabled:pointer-events-none",258 fullWidth ? "w-full" : "",259 className,260 ]261 .filter(Boolean)262 .join(" ")}263 style={{264 background: cfg.bezel,265 boxShadow: cfg.bezelShadow,266 ...style,267 }}268 disabled={disabled}269 {...handlers}270 {...rest}271 >272 <span273 className={[274 "btn-face relative overflow-hidden flex items-center justify-center gap-1.5 font-medium",275 faceRadius[size],276 cfg.text,277 square ? squarePadding[size] : ["w-full", innerPadding[size]].join(" "),278 ].join(" ")}279 style={{280 background: cfg.face,281 boxShadow: faceShadow,282 textShadow: cfg.textShadow,283 transform: pressed284 ? "translateY(0.5px) scale(0.985)"285 : hovered286 ? "translateY(-0.5px)"287 : "none",288 transition:289 "transform 240ms var(--ease-spring), box-shadow 200ms ease-out",290 }}291 >292 <span293 aria-hidden="true"294 className={[295 "pointer-events-none absolute inset-0 origin-bottom transition-transform duration-300 ease-out",296 pressed297 ? "scale-y-100"298 : "scale-y-0 group-hover/btn:scale-y-100",299 cfg.fill,300 ].join(" ")}301 />302 <span className="btn-icon-nudge relative z-10 inline-flex items-center gap-1.5">303 {children}304 </span>305 </span>306 </button>307 );308 }309
310 const cfg = flatConfig[variant];311 const currentShadow = pressed312 ? cfg.pressedShadow313 : hovered314 ? cfg.hoverShadow315 : cfg.shadow;316
317 return (318 <button319 data-btn320 className={[321 "btn-face group/btn relative overflow-hidden inline-flex items-center justify-center gap-1.5 font-medium cursor-pointer select-none whitespace-nowrap",322 "transition-colors duration-200 ease-out",323 "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink",324 "disabled:opacity-40 disabled:pointer-events-none",325 cfg.bg,326 flatSizeStyles[size],327 fullWidth ? "w-full" : "",328 className,329 ]330 .filter(Boolean)331 .join(" ")}332 style={{333 background: cfg.background,334 boxShadow: currentShadow,335 transform: pressed336 ? "translateY(0.5px) scale(0.97)"337 : hovered && cfg.background338 ? "translateY(-0.5px)"339 : "none",340 transition:341 "transform 240ms var(--ease-spring), box-shadow 200ms ease-out, color 200ms ease-out",342 ...style,343 }}344 disabled={disabled}345 {...handlers}346 {...rest}347 >348 <span349 aria-hidden="true"350 className={[351 "pointer-events-none absolute inset-0 origin-bottom transition-transform duration-300 ease-out",352 pressed ? "scale-y-100" : "scale-y-0 group-hover/btn:scale-y-100",353 cfg.fill,354 ].join(" ")}355 />356 <span className="relative z-10 inline-flex items-center gap-1.5">357 {children}358 </span>359 </button>360 );361}components/ui/CloseButton.tsxtsx
1"use client";2
3import React from "react";4
5interface CloseButtonProps {6 onClick?: () => void;7 label?: string;8 size?: "sm" | "md";9 tone?: "light" | "dark";10 className?: string;11}12
13const sizes = {14 sm: { box: "w-6 h-6 rounded-[7px]", icon: 9 },15 md: { box: "w-7 h-7 rounded-[8px]", icon: 10 },16};17
18/* Quiet until hovered, then it rises into a raised face like every other19 pressable thing — so it reads as a control, not decoration. */20export function CloseButton({21 onClick,22 label = "Close",23 size = "md",24 tone = "light",25 className = "",26}: CloseButtonProps) {27 const dark = tone === "dark";28 const s = sizes[size];29
30 return (31 <button32 type="button"33 onClick={onClick}34 aria-label={label}35 className={[36 "group/close relative shrink-0 flex items-center justify-center cursor-pointer",37 "transition-[color,background,scale] duration-150 active:scale-95",38 "focus-visible:outline-2 focus-visible:outline-offset-2",39 dark40 ? "text-white/45 hover:text-white focus-visible:outline-white"41 : "text-ink-faint hover:text-ink focus-visible:outline-ink",42 s.box,43 className,44 ].join(" ")}45 >46 <span47 aria-hidden48 className={[49 "absolute inset-0 rounded-[inherit] opacity-0 group-hover/close:opacity-100 transition-opacity duration-150",50 dark51 ? "[background:var(--tile-dark)] [box-shadow:var(--tile-dark-shadow)]"52 : "[background:var(--face)] [box-shadow:var(--face-shadow)]",53 ].join(" ")}54 />55 <svg56 className="relative"57 width={s.icon}58 height={s.icon}59 fill="none"60 stroke="currentColor"61 strokeWidth="2.5"62 viewBox="0 0 24 24"63 >64 <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />65 </svg>66 </button>67 );68}components/ui/FieldMessage.tsxtsx
1"use client";2
3import React from "react";4
5/* The label above a field and the line under it, shared by every form6 control so they read as one family. */7
8export function FieldLabel({9 id,10 htmlFor,11 children,12}: {13 id?: string;14 htmlFor?: string;15 children: React.ReactNode;16}) {17 const className =18 "text-[12.5px] font-medium text-ink-label tracking-[-0.005em] transition-colors duration-200 group-focus-within/field:text-ink-strong";19 return htmlFor ? (20 <label id={id} htmlFor={htmlFor} className={className}>21 {children}22 </label>23 ) : (24 <span id={id} className={className}>25 {children}26 </span>27 );28}29
30/* An error wins over a hint; either is announced through `id` */31export function FieldMessage({ id, error, hint }: { id?: string; error?: string; hint?: string }) {32 if (error) {33 return (34 <p id={id} role="alert" className="text-[11.5px] text-danger font-medium flex items-center gap-1 mt-0.5">35 <svg aria-hidden width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">36 <circle cx="12" cy="12" r="10" />37 <path strokeLinecap="round" d="M12 8v4m0 4h.01" />38 </svg>39 {error}40 </p>41 );42 }43 if (hint) {44 return (45 <p id={id} className="text-[11.5px] text-ink-muted mt-0.5">46 {hint}47 </p>48 );49 }50 return null;51}52
53/* A small raised key that sits inside a field or a panel: steppers,54 month arrows, clear buttons. It sinks flush while held. */55export function FieldKey({56 label,57 disabled,58 onClick,59 onPress,60 onRelease,61 focusable = true,62 size = 28,63 children,64}: {65 label: string;66 disabled?: boolean;67 onClick?: () => void;68 /** Pointer went down (for hold-to-repeat) */69 onPress?: () => void;70 onRelease?: () => void;71 focusable?: boolean;72 size?: number;73 children: React.ReactNode;74}) {75 const [pressed, setPressed] = React.useState(false);76 const release = () => {77 setPressed(false);78 onRelease?.();79 };80 return (81 <button82 type="button"83 aria-label={label}84 tabIndex={focusable ? undefined : -1}85 disabled={disabled}86 onClick={onClick}87 onPointerDown={(e) => {88 if (e.button !== 0) return;89 if (!focusable) e.preventDefault(); // keep focus in the field90 setPressed(true);91 onPress?.();92 }}93 onPointerUp={release}94 onPointerLeave={() => pressed && release()}95 onPointerCancel={release}96 className="grid shrink-0 cursor-pointer place-items-center rounded-[9px] text-ink-muted transition-[box-shadow,color,opacity] duration-150 hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ink disabled:opacity-35 disabled:pointer-events-none"97 style={{98 width: size,99 height: size,100 background: "var(--face)",101 boxShadow: pressed ? "var(--face-pressed)" : "var(--key-shadow)",102 }}103 >104 <svg105 aria-hidden106 width="12"107 height="12"108 fill="none"109 stroke="currentColor"110 strokeWidth="2.25"111 strokeLinecap="round"112 strokeLinejoin="round"113 viewBox="0 0 24 24"114 style={{115 transform: pressed ? "translateY(0.5px) scale(0.92)" : "none",116 transition: "transform 150ms var(--ease-spring)",117 }}118 >119 {children}120 </svg>121 </button>122 );123}usage
tsx
import { FileUpload } from "@blurrow/ui";tsx
<FileUpload label="attachments" upload={(file, onProgress) => send(file, onProgress)} />examples
one image
cover image
drop an image, or browsepng, jpg or webp, up to 2 mb
props
straight from the source, so they're always up to date.
propsts
interface UploadItem { id: string; file: File; /** 0–100 while uploading */ progress: number; status: "ready" | "uploading" | "done" | "error"; error?: string;}
interface FileUploadProps { /** Same as the input's accept: "image/*", ".pdf,.docx" */ accept?: string; multiple?: boolean; /** In bytes; bigger files land with an error instead of uploading */ maxSize?: number; maxFiles?: number; /** Upload each file as it lands. Call onProgress with 0–100; throw to fail it */ upload?: (file: File, onProgress: (pct: number) => void) => Promise<void>; /** Every change to the list: added, progressed, finished, removed */ onChange?: (items: UploadItem[]) => void; label?: string; hint?: string; /** The words in the empty tray */ prompt?: string; disabled?: boolean; className?: string;}