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-upload

or 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
"use client";
import React, { useEffect, useId, useRef, useState } from "react";
import { Button } from "./Button";
import { CloseButton } from "./CloseButton";
import { FieldLabel, FieldMessage } from "./FieldMessage";
export 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;
}
export function formatBytes(n: number) {
if (n < 1024) return `${n} b`;
const units = ["kb", "mb", "gb"];
let i = -1;
do {
n /= 1024;
i++;
} while (n >= 1024 && i < units.length - 1);
return `${n.toFixed(n < 10 ? 1 : 0)} ${units[i]}`;
}
/* Matches a file against an accept string the way the browser's picker does */
function accepts(file: File, accept?: string) {
if (!accept) return true;
return accept.split(",").some((rule) => {
const r = rule.trim().toLowerCase();
if (r.startsWith(".")) return file.name.toLowerCase().endsWith(r);
if (r.endsWith("/*")) return file.type.startsWith(r.slice(0, -1));
return file.type === r;
});
}
let seq = 0;
/* Something you fill, so it's built as a field: a wide sunken tray. Drag
files over it and it lifts flush and draws its border, the way an
Input does when it takes focus. What you drop lands inside it as
raised chips; each has a groove along its foot that fills while it
uploads, then fades once it's done. */
export function FileUpload({
accept,
multiple = true,
maxSize,
maxFiles,
upload,
onChange,
label,
hint,
prompt,
disabled = false,
className = "",
}: FileUploadProps) {
const uid = useId();
const inputRef = useRef<HTMLInputElement>(null);
const [items, setItems] = useState<UploadItem[]>([]);
const [previews, setPreviews] = useState<Record<string, string>>({});
const [over, setOver] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const depth = useRef(0);
// Report every change to the list, after it renders
const report = useRef(onChange);
useEffect(() => {
report.current = onChange;
});
useEffect(() => {
report.current?.(items);
}, [items]);
// Image previews are object URLs; give them back when they're gone
const urls = useRef(previews);
useEffect(() => {
urls.current = previews;
});
useEffect(() => () => Object.values(urls.current).forEach(URL.revokeObjectURL), []);
const patch = (id: string, p: Partial<UploadItem>) =>
setItems((list) => list.map((it) => (it.id === id ? { ...it, ...p } : it)));
const start = (item: UploadItem) => {
if (!upload) return;
patch(item.id, { status: "uploading", progress: 0 });
upload(item.file, (pct) => patch(item.id, { progress: Math.max(0, Math.min(100, pct)) }))
.then(() => patch(item.id, { status: "done", progress: 100 }))
.catch((err: unknown) =>
patch(item.id, { status: "error", error: err instanceof Error ? err.message : "upload failed" }),
);
};
const add = (files: FileList | File[]) => {
if (disabled) return;
let list = Array.from(files);
if (!multiple) list = list.slice(0, 1);
const room = maxFiles ? Math.max(0, maxFiles - (multiple ? items.length : 0)) : Infinity;
setNotice(list.length > room ? `only ${maxFiles} file${maxFiles === 1 ? "" : "s"} can go in` : null);
const next: UploadItem[] = list.slice(0, room).map((file) => {
const id = `${uid}-${++seq}`;
if (!accepts(file, accept)) return { id, file, progress: 0, status: "error", error: "not a type this takes" };
if (maxSize && file.size > maxSize)
return { id, file, progress: 0, status: "error", error: `over ${formatBytes(maxSize)}` };
return { id, file, progress: 0, status: "ready" };
});
const shots: Record<string, string> = {};
next.forEach((it) => {
if (it.file.type.startsWith("image/") && it.status !== "error") shots[it.id] = URL.createObjectURL(it.file);
});
if (!multiple) Object.values(previews).forEach(URL.revokeObjectURL);
setPreviews((p) => (multiple ? { ...p, ...shots } : shots));
setItems((cur) => (multiple ? [...cur, ...next] : next));
next.filter((it) => it.status === "ready").forEach(start);
};
const remove = (id: string) => {
if (previews[id]) URL.revokeObjectURL(previews[id]);
setPreviews((p) => {
const rest = { ...p };
delete rest[id];
return rest;
});
setItems((list) => list.filter((it) => it.id !== id));
};
const browse = () => !disabled && inputRef.current?.click();
const full = !!maxFiles && multiple && items.length >= maxFiles;
const empty = items.length === 0;
return (
<div className={["group/field flex w-full flex-col gap-1.5", className].join(" ")}>
{label && <FieldLabel id={`${uid}-label`}>{label}</FieldLabel>}
{/* The tray: a field, so data-open gives it Input's focused well */}
<div
role="group"
aria-labelledby={label ? `${uid}-label` : undefined}
aria-describedby={hint ? `${uid}-hint` : undefined}
data-open={over || undefined}
onDragEnter={(e) => {
e.preventDefault();
if (disabled) return;
depth.current++;
setOver(true);
}}
onDragOver={(e) => e.preventDefault()}
onDragLeave={() => {
// dragleave fires for every child crossed; only the last one counts
if (--depth.current <= 0) setOver(false);
}}
onDrop={(e) => {
e.preventDefault();
depth.current = 0;
setOver(false);
add(e.dataTransfer.files);
}}
className={[
"field relative flex flex-col rounded-[18px] p-2",
disabled ? "pointer-events-none opacity-40" : "",
].join(" ")}
>
{empty ? (
<div
className="flex cursor-pointer flex-col items-center gap-3.5 px-6 py-9 text-center"
onClick={(e) => e.target === e.currentTarget && browse()}
>
<span className="flex flex-col gap-1">
<span className="text-[14px] font-medium text-ink tracking-[-0.01em]">
{over ? "let go to drop them in" : prompt ?? (multiple ? "drop files here" : "drop a file here")}
</span>
{hint && (
<span id={`${uid}-hint`} className="text-[12px] text-ink-muted">
{hint}
</span>
)}
</span>
<Button variant="secondary" size="sm" onClick={browse} disabled={disabled}>
{multiple ? "choose files" : "choose a file"}
</Button>
</div>
) : (
<>
<ul aria-label="files" className="flex flex-col gap-1.5">
{items.map((it) => (
<FileChip key={it.id} item={it} preview={previews[it.id]} onRemove={() => remove(it.id)} />
))}
</ul>
<div className="flex items-center justify-between gap-3 pl-2.5 pr-0.5 pt-2.5 pb-0.5">
<span id={`${uid}-hint`} className="truncate text-[12px] text-ink-muted">
{over ? "let go to add them" : hint ?? `${items.length} file${items.length === 1 ? "" : "s"}`}
</span>
{!full && (
<Button variant="secondary" size="sm" onClick={browse} disabled={disabled}>
{multiple ? "add more" : "replace"}
</Button>
)}
</div>
</>
)}
<input
ref={inputRef}
type="file"
accept={accept}
multiple={multiple}
disabled={disabled}
tabIndex={-1}
aria-hidden
className="sr-only"
onChange={(e) => {
if (e.target.files) add(e.target.files);
e.target.value = ""; // so picking the same file again still fires
}}
/>
</div>
{notice && <FieldMessage error={notice} />}
</div>
);
}
/* A raised chip sitting in the tray. The groove along its foot is a
Progress track in miniature; it fills, then fades once the file is up. */
function FileChip({ item, preview, onRemove }: { item: UploadItem; preview?: string; onRemove: () => void }) {
const failed = item.status === "error";
const meta = failed
? item.error
: item.status === "uploading"
? `${formatBytes(item.file.size)} · ${Math.round(item.progress)}%`
: item.status === "done"
? `${formatBytes(item.file.size)} · uploaded`
: formatBytes(item.file.size);
return (
<li
className="relative flex items-center gap-3 overflow-hidden rounded-[12px] pt-2.5 pb-3.5 pl-2.5 pr-2"
style={{
background: "var(--face)",
boxShadow: "var(--thumb-shadow)",
animation: "select-option-in 320ms var(--ease-out-soft) both",
}}
>
{preview && (
// eslint-disable-next-line @next/next/no-img-element -- a local object URL, nothing to optimise
<img
src={preview}
alt=""
className="h-9 w-9 shrink-0 rounded-[8px] object-cover"
style={{ boxShadow: "inset 0 0 0 1px var(--hairline), var(--key-shadow)" }}
/>
)}
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-[13px] font-medium text-ink">{item.file.name}</span>
<span
className={[
"flex items-center gap-1 truncate text-[11.5px] tabular-nums",
failed ? "text-danger" : "text-ink-muted",
].join(" ")}
>
{item.status === "done" && (
<svg aria-hidden width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2.75" viewBox="0 0 24 24" className="text-success">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
{meta}
</span>
</span>
<CloseButton size="sm" label={`remove ${item.file.name}`} onClick={onRemove} />
{/* The groove along the chip's foot */}
<span
aria-hidden={item.status !== "uploading"}
role={item.status === "uploading" ? "progressbar" : undefined}
aria-label={item.status === "uploading" ? `uploading ${item.file.name}` : undefined}
aria-valuenow={item.status === "uploading" ? Math.round(item.progress) : undefined}
aria-valuemin={0}
aria-valuemax={100}
className="absolute inset-x-2.5 bottom-[6px] h-[3px] rounded-full transition-opacity duration-500"
style={{
background: "var(--track)",
boxShadow: "var(--track-shadow)",
opacity: item.status === "uploading" ? 1 : 0,
transitionDelay: item.status === "done" ? "400ms" : "0ms",
}}
>
<span
className="block h-full rounded-full"
style={{
width: `${item.progress}%`,
background: "var(--face-inverse)",
transition: "width 300ms var(--ease-out-soft)",
}}
/>
</span>
</li>
);
}
components/ui/Button.tsxtsx
"use client";
import React, { useState, useRef, useCallback } from "react";
type ButtonVariant = "primary" | "secondary" | "ghost" | "outline";
type ButtonSize = "sm" | "md" | "lg" | "icon";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
children: React.ReactNode;
fullWidth?: boolean;
/** Surface the button sits on. On dark cards, secondary/outline switch to a
dark face so they don't glare against the card. */
tone?: "light" | "dark";
/** Equal width and height, for icon-only layered buttons. */
square?: boolean;
}
/* Layered buttons are a raised face seated in a machined bezel. The bezel
never moves; the face lifts on hover and sinks flush when pressed. */
const shellConfig: Record<
string,
{
bezel: string;
bezelShadow: string;
face: string;
faceShadow: string;
faceHoverShadow: string;
facePressedShadow: string;
text: string;
textShadow: string;
fill: string;
}
> = {
primary: {
bezel: "var(--bezel-inverse)",
bezelShadow: "var(--bezel-inverse-shadow)",
face: "var(--face-inverse)",
faceShadow: "var(--face-inverse-shadow)",
faceHoverShadow: "var(--face-inverse-shadow-hover)",
facePressedShadow: "var(--face-inverse-pressed)",
text: "text-ink-inverse",
textShadow: "var(--emboss-inverse-deep)",
fill: "bg-ink-inverse/[0.05]",
},
secondary: {
bezel: "var(--bezel)",
bezelShadow: "var(--bezel-shadow)",
face: "var(--face)",
faceShadow: "var(--face-shadow)",
faceHoverShadow: "var(--face-shadow-hover)",
facePressedShadow: "var(--face-pressed)",
text: "text-ink-soft",
textShadow: "var(--emboss)",
fill: "bg-ink/[0.025]",
},
outline: {
bezel: "var(--outline-bezel)",
bezelShadow: "var(--outline-bezel-shadow)",
face: "var(--face)",
faceShadow: "var(--outline-face-shadow)",
faceHoverShadow: "var(--outline-face-shadow-hover)",
facePressedShadow: "var(--face-pressed)",
text: "text-ink-label",
textShadow: "var(--emboss)",
fill: "bg-ink/[0.025]",
},
};
/* Secondary on a dark surface: a raised face a few steps lighter than the
card — present, but in the same family of tones. It's the mid material,
the same one secondary keys wear everywhere in the dark theme. */
const darkSecondary = {
bezel: "var(--bezel-mid)",
bezelShadow: "var(--bezel-mid-shadow)",
face: "var(--face-mid)",
faceShadow: "var(--face-mid-shadow)",
faceHoverShadow: "var(--face-mid-shadow-hover)",
facePressedShadow: "var(--face-dark-pressed)",
text: "text-neutral-100",
textShadow: "var(--emboss-inverse)",
fill: "bg-white/[0.04]",
};
/* Flat buttons (ghost, icon) are a single face without a bezel. */
const flatConfig: Record<
ButtonVariant,
{
bg: string;
background?: string;
fill: string;
shadow: string;
hoverShadow: string;
pressedShadow: string;
}
> = {
primary: {
bg: "text-ink-inverse",
background: "var(--face-inverse)",
fill: "bg-ink-inverse/[0.08]",
shadow: "var(--face-inverse-shadow)",
hoverShadow: "var(--face-inverse-float-shadow)",
pressedShadow: "var(--face-inverse-pressed)",
},
secondary: {
bg: "text-ink-soft",
background: "var(--face)",
fill: "bg-ink/[0.035]",
shadow: "var(--face-shadow)",
hoverShadow: "var(--face-float-shadow)",
pressedShadow: "var(--face-pressed)",
},
ghost: {
bg: "bg-transparent text-ink-muted hover:text-ink-soft",
fill: "bg-ink/[0.05]",
shadow: "none",
hoverShadow: "var(--ghost-shadow-hover)",
pressedShadow: "var(--ghost-shadow-pressed)",
},
outline: {
bg: "bg-transparent text-ink-label",
fill: "bg-ink/[0.035]",
shadow: "var(--outline-shadow)",
hoverShadow: "var(--outline-shadow-hover)",
pressedShadow: "var(--outline-shadow-pressed)",
},
};
/* Bezel radius minus bezel padding = face radius, so the curves stay concentric. */
const shellRadius: Record<ButtonSize, string> = {
sm: "rounded-[12px] p-[3px]",
md: "rounded-[13px] p-[3px]",
lg: "rounded-[15px] p-[3.5px]",
icon: "",
};
const faceRadius: Record<ButtonSize, string> = {
sm: "rounded-[9px]",
md: "rounded-[10px]",
lg: "rounded-[11.5px]",
icon: "",
};
const innerPadding: Record<ButtonSize, string> = {
sm: "px-4 py-2 text-[13px]",
md: "px-5 py-2.5 text-sm",
lg: "px-7 py-3 text-[15px]",
icon: "w-7 h-7 p-0 text-[13px]",
};
const squarePadding: Record<ButtonSize, string> = {
sm: "w-9 h-9 p-0 text-[13px]",
md: "w-10 h-10 p-0 text-sm",
lg: "w-12 h-12 p-0 text-[15px]",
icon: "",
};
const flatSizeStyles: Record<ButtonSize, string> = {
sm: "px-4 py-2.5 text-[13px] rounded-[10px]",
md: "px-5 py-3 text-sm rounded-[10px]",
lg: "px-7 py-3.5 text-[15px] rounded-xl",
icon: "w-8 h-8 p-0 rounded-[10px] text-[13px]",
};
export function Button({
variant = "primary",
size = "md",
fullWidth = false,
tone = "light",
square = false,
className = "",
children,
disabled,
onClick,
onPointerDown,
onPointerUp,
onPointerLeave,
onPointerCancel,
onPointerEnter,
style,
...rest
}: ButtonProps) {
const [pressed, setPressed] = useState(false);
const [hovered, setHovered] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/* The press state is the button's own; handlers passed in run alongside
it rather than replacing it */
const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLButtonElement>) => {
if (timerRef.current) clearTimeout(timerRef.current);
setPressed(true);
onPointerDown?.(e);
},
[onPointerDown],
);
const handlePointerUp = useCallback(
(e: React.PointerEvent<HTMLButtonElement>) => {
timerRef.current = setTimeout(() => setPressed(false), 150);
onPointerUp?.(e);
},
[onPointerUp],
);
const handlePointerCancel = useCallback(
(e: React.PointerEvent<HTMLButtonElement>) => {
timerRef.current = setTimeout(() => setPressed(false), 150);
onPointerCancel?.(e);
},
[onPointerCancel],
);
const handlePointerLeave = useCallback(
(e: React.PointerEvent<HTMLButtonElement>) => {
setHovered(false);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setPressed(false), 150);
onPointerLeave?.(e);
},
[onPointerLeave],
);
const layered = variant !== "ghost" && size !== "icon";
const handlers = {
onClick,
onPointerDown: handlePointerDown,
onPointerUp: handlePointerUp,
onPointerLeave: handlePointerLeave,
onPointerCancel: handlePointerCancel,
onPointerEnter: (e: React.PointerEvent<HTMLButtonElement>) => {
setHovered(true);
onPointerEnter?.(e);
},
};
if (layered) {
const cfg =
tone === "dark" && (variant === "secondary" || variant === "outline")
? darkSecondary
: shellConfig[variant];
const faceShadow = pressed
? cfg.facePressedShadow
: hovered
? cfg.faceHoverShadow
: cfg.faceShadow;
return (
<button
data-btn
className={[
"group/btn relative inline-flex cursor-pointer select-none whitespace-nowrap",
shellRadius[size],
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink",
"disabled:opacity-40 disabled:pointer-events-none",
fullWidth ? "w-full" : "",
className,
]
.filter(Boolean)
.join(" ")}
style={{
background: cfg.bezel,
boxShadow: cfg.bezelShadow,
...style,
}}
disabled={disabled}
{...handlers}
{...rest}
>
<span
className={[
"btn-face relative overflow-hidden flex items-center justify-center gap-1.5 font-medium",
faceRadius[size],
cfg.text,
square ? squarePadding[size] : ["w-full", innerPadding[size]].join(" "),
].join(" ")}
style={{
background: cfg.face,
boxShadow: faceShadow,
textShadow: cfg.textShadow,
transform: pressed
? "translateY(0.5px) scale(0.985)"
: hovered
? "translateY(-0.5px)"
: "none",
transition:
"transform 240ms var(--ease-spring), box-shadow 200ms ease-out",
}}
>
<span
aria-hidden="true"
className={[
"pointer-events-none absolute inset-0 origin-bottom transition-transform duration-300 ease-out",
pressed
? "scale-y-100"
: "scale-y-0 group-hover/btn:scale-y-100",
cfg.fill,
].join(" ")}
/>
<span className="btn-icon-nudge relative z-10 inline-flex items-center gap-1.5">
{children}
</span>
</span>
</button>
);
}
const cfg = flatConfig[variant];
const currentShadow = pressed
? cfg.pressedShadow
: hovered
? cfg.hoverShadow
: cfg.shadow;
return (
<button
data-btn
className={[
"btn-face group/btn relative overflow-hidden inline-flex items-center justify-center gap-1.5 font-medium cursor-pointer select-none whitespace-nowrap",
"transition-colors duration-200 ease-out",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink",
"disabled:opacity-40 disabled:pointer-events-none",
cfg.bg,
flatSizeStyles[size],
fullWidth ? "w-full" : "",
className,
]
.filter(Boolean)
.join(" ")}
style={{
background: cfg.background,
boxShadow: currentShadow,
transform: pressed
? "translateY(0.5px) scale(0.97)"
: hovered && cfg.background
? "translateY(-0.5px)"
: "none",
transition:
"transform 240ms var(--ease-spring), box-shadow 200ms ease-out, color 200ms ease-out",
...style,
}}
disabled={disabled}
{...handlers}
{...rest}
>
<span
aria-hidden="true"
className={[
"pointer-events-none absolute inset-0 origin-bottom transition-transform duration-300 ease-out",
pressed ? "scale-y-100" : "scale-y-0 group-hover/btn:scale-y-100",
cfg.fill,
].join(" ")}
/>
<span className="relative z-10 inline-flex items-center gap-1.5">
{children}
</span>
</button>
);
}
components/ui/CloseButton.tsxtsx
"use client";
import React from "react";
interface CloseButtonProps {
onClick?: () => void;
label?: string;
size?: "sm" | "md";
tone?: "light" | "dark";
className?: string;
}
const sizes = {
sm: { box: "w-6 h-6 rounded-[7px]", icon: 9 },
md: { box: "w-7 h-7 rounded-[8px]", icon: 10 },
};
/* Quiet until hovered, then it rises into a raised face like every other
pressable thing — so it reads as a control, not decoration. */
export function CloseButton({
onClick,
label = "Close",
size = "md",
tone = "light",
className = "",
}: CloseButtonProps) {
const dark = tone === "dark";
const s = sizes[size];
return (
<button
type="button"
onClick={onClick}
aria-label={label}
className={[
"group/close relative shrink-0 flex items-center justify-center cursor-pointer",
"transition-[color,background,scale] duration-150 active:scale-95",
"focus-visible:outline-2 focus-visible:outline-offset-2",
dark
? "text-white/45 hover:text-white focus-visible:outline-white"
: "text-ink-faint hover:text-ink focus-visible:outline-ink",
s.box,
className,
].join(" ")}
>
<span
aria-hidden
className={[
"absolute inset-0 rounded-[inherit] opacity-0 group-hover/close:opacity-100 transition-opacity duration-150",
dark
? "[background:var(--tile-dark)] [box-shadow:var(--tile-dark-shadow)]"
: "[background:var(--face)] [box-shadow:var(--face-shadow)]",
].join(" ")}
/>
<svg
className="relative"
width={s.icon}
height={s.icon}
fill="none"
stroke="currentColor"
strokeWidth="2.5"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
);
}
components/ui/FieldMessage.tsxtsx
"use client";
import React from "react";
/* The label above a field and the line under it, shared by every form
control so they read as one family. */
export function FieldLabel({
id,
htmlFor,
children,
}: {
id?: string;
htmlFor?: string;
children: React.ReactNode;
}) {
const className =
"text-[12.5px] font-medium text-ink-label tracking-[-0.005em] transition-colors duration-200 group-focus-within/field:text-ink-strong";
return htmlFor ? (
<label id={id} htmlFor={htmlFor} className={className}>
{children}
</label>
) : (
<span id={id} className={className}>
{children}
</span>
);
}
/* An error wins over a hint; either is announced through `id` */
export function FieldMessage({ id, error, hint }: { id?: string; error?: string; hint?: string }) {
if (error) {
return (
<p id={id} role="alert" className="text-[11.5px] text-danger font-medium flex items-center gap-1 mt-0.5">
<svg aria-hidden width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" />
<path strokeLinecap="round" d="M12 8v4m0 4h.01" />
</svg>
{error}
</p>
);
}
if (hint) {
return (
<p id={id} className="text-[11.5px] text-ink-muted mt-0.5">
{hint}
</p>
);
}
return null;
}
/* A small raised key that sits inside a field or a panel: steppers,
month arrows, clear buttons. It sinks flush while held. */
export function FieldKey({
label,
disabled,
onClick,
onPress,
onRelease,
focusable = true,
size = 28,
children,
}: {
label: string;
disabled?: boolean;
onClick?: () => void;
/** Pointer went down (for hold-to-repeat) */
onPress?: () => void;
onRelease?: () => void;
focusable?: boolean;
size?: number;
children: React.ReactNode;
}) {
const [pressed, setPressed] = React.useState(false);
const release = () => {
setPressed(false);
onRelease?.();
};
return (
<button
type="button"
aria-label={label}
tabIndex={focusable ? undefined : -1}
disabled={disabled}
onClick={onClick}
onPointerDown={(e) => {
if (e.button !== 0) return;
if (!focusable) e.preventDefault(); // keep focus in the field
setPressed(true);
onPress?.();
}}
onPointerUp={release}
onPointerLeave={() => pressed && release()}
onPointerCancel={release}
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"
style={{
width: size,
height: size,
background: "var(--face)",
boxShadow: pressed ? "var(--face-pressed)" : "var(--key-shadow)",
}}
>
<svg
aria-hidden
width="12"
height="12"
fill="none"
stroke="currentColor"
strokeWidth="2.25"
strokeLinecap="round"
strokeLinejoin="round"
viewBox="0 0 24 24"
style={{
transform: pressed ? "translateY(0.5px) scale(0.92)" : "none",
transition: "transform 150ms var(--ease-spring)",
}}
>
{children}
</svg>
</button>
);
}

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;
}