File Manager Storage
Original · freestorage usage widget
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/filemgr-storage.json"use client";
import { useCallback, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { motion, useReducedMotion } from "motion/react";
type Category = {
id: string;
label: string;
hint: string;
bytes: number;
stroke: string;
dot: string;
bar: string;
};
type Plan = {
id: string;
name: string;
quota: number;
quotaLabel: string;
seats: string;
};
type ReclaimItem = {
id: string;
label: string;
hint: string;
bytes: number;
};
const GB = 1_000_000_000;
const CATEGORIES: Category[] = [
{
id: "video",
label: "Video & renders",
hint: "1,842 files · ProRes masters, launch cuts",
bytes: 612.4 * GB,
stroke: "stroke-rose-500 dark:stroke-rose-400",
dot: "bg-rose-500 dark:bg-rose-400",
bar: "bg-rose-500 dark:bg-rose-400",
},
{
id: "photos",
label: "Photo library",
hint: "24,106 files · RAW shoots, product stills",
bytes: 214.8 * GB,
stroke: "stroke-violet-500 dark:stroke-violet-400",
dot: "bg-violet-500 dark:bg-violet-400",
bar: "bg-violet-500 dark:bg-violet-400",
},
{
id: "design",
label: "Design files",
hint: "3,290 files · Figma exports, 3D scenes",
bytes: 96.3 * GB,
stroke: "stroke-indigo-500 dark:stroke-indigo-400",
dot: "bg-indigo-500 dark:bg-indigo-400",
bar: "bg-indigo-500 dark:bg-indigo-400",
},
{
id: "docs",
label: "Documents",
hint: "11,478 files · Contracts, decks, models",
bytes: 38.5 * GB,
stroke: "stroke-emerald-500 dark:stroke-emerald-400",
dot: "bg-emerald-500 dark:bg-emerald-400",
bar: "bg-emerald-500 dark:bg-emerald-400",
},
{
id: "code",
label: "Code & archives",
hint: "612 files · Repo mirrors, nightly backups",
bytes: 21.7 * GB,
stroke: "stroke-sky-500 dark:stroke-sky-400",
dot: "bg-sky-500 dark:bg-sky-400",
bar: "bg-sky-500 dark:bg-sky-400",
},
{
id: "reclaim",
label: "Trash & old versions",
hint: "Safe to clear — nothing here is live",
bytes: 0,
stroke: "stroke-amber-500 dark:stroke-amber-400",
dot: "bg-amber-500 dark:bg-amber-400",
bar: "bg-amber-500 dark:bg-amber-400",
},
];
const PLANS: Plan[] = [
{ id: "starter", name: "Starter", quota: 200 * GB, quotaLabel: "200 GB", seats: "3 seats" },
{ id: "studio", name: "Studio", quota: 2000 * GB, quotaLabel: "2 TB", seats: "12 seats" },
{ id: "scale", name: "Scale", quota: 8000 * GB, quotaLabel: "8 TB", seats: "40 seats" },
];
const RECLAIM: ReclaimItem[] = [
{ id: "rc-trash", label: "Trash", hint: "1,204 files deleted 30+ days ago", bytes: 24.6 * GB },
{ id: "rc-versions", label: "Previous file versions", hint: "3,918 revisions older than 90 days", bytes: 61.2 * GB },
{ id: "rc-dupes", label: "Duplicate uploads", hint: "142 byte-identical copies", bytes: 8.9 * GB },
];
const RADIUS = 64;
const CIRC = 2 * Math.PI * RADIUS;
const EASE: [number, number, number, number] = [0.22, 1, 0.36, 1];
const UNITS = ["B", "KB", "MB", "GB", "TB"];
function formatSize(bytes: number): string {
if (bytes <= 0) return "0 GB";
let value = bytes;
let unit = 0;
while (value >= 1000 && unit < UNITS.length - 1) {
value /= 1000;
unit += 1;
}
const digits = unit >= 4 ? 2 : unit === 3 ? 1 : 0;
return `${value.toFixed(digits)} ${UNITS[unit]}`;
}
function formatPercent(value: number): string {
if (value <= 0) return "0";
if (value < 1) return value.toFixed(1);
return String(Math.round(value));
}
function AlertIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round">
<path d="M10.7 4.2 2.9 17.5A1.5 1.5 0 0 0 4.2 19.8h15.6a1.5 1.5 0 0 0 1.3-2.3L13.3 4.2a1.5 1.5 0 0 0-2.6 0Z" />
<path d="M12 9.5v4" />
<path d="M12 16.6h.01" />
</svg>
);
}
function SweepIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 3.5 20 9" />
<path d="M13 5 6.6 11.4a2 2 0 0 0 0 2.83l3.17 3.17a2 2 0 0 0 2.83 0L19 11" />
<path d="M4 21h7" />
<path d="M8.5 14.5 5 18" />
</svg>
);
}
function UndoIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round">
<path d="M4 8h10a5.5 5.5 0 0 1 0 11h-4" />
<path d="m7.5 4.5-3.5 3.5 3.5 3.5" />
</svg>
);
}
function DriveIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
<rect x="2.5" y="13" width="19" height="7" rx="2" />
<path d="M5.4 13 8 4.6A1.5 1.5 0 0 1 9.4 3.5h5.2A1.5 1.5 0 0 1 16 4.6L18.6 13" />
<path d="M6.5 16.5h.01" />
<path d="M10 16.5h.01" />
</svg>
);
}
export default function FileManagerStorage() {
const reduce = useReducedMotion();
const [planId, setPlanId] = useState("studio");
const [excluded, setExcluded] = useState<Set<string>>(() => new Set<string>());
const [picked, setPicked] = useState<Set<string>>(() => new Set<string>());
const [cleared, setCleared] = useState<Set<string>>(() => new Set<string>());
const [highlight, setHighlight] = useState<string | null>(null);
const [status, setStatus] = useState("Studio plan · 1.08 TB of 2 TB used");
const planRefs = useRef<Array<HTMLButtonElement | null>>([]);
const plan = useMemo(() => PLANS.find((p) => p.id === planId) ?? PLANS[1], [planId]);
const sizeOf = useCallback(
(category: Category) =>
category.id === "reclaim"
? RECLAIM.filter((r) => !cleared.has(r.id)).reduce((sum, r) => sum + r.bytes, 0)
: category.bytes,
[cleared],
);
const used = useMemo(
() => CATEGORIES.filter((c) => !excluded.has(c.id)).reduce((sum, c) => sum + sizeOf(c), 0),
[excluded, sizeOf],
);
const over = used > plan.quota;
const denom = Math.max(plan.quota, used, 1);
const percent = (used / plan.quota) * 100;
const segments = useMemo(() => {
const list: Array<{ id: string; stroke: string; drawn: number; offset: number }> = [];
let cursor = 0;
for (const category of CATEGORIES) {
const bytes = excluded.has(category.id) ? 0 : sizeOf(category);
const length = (bytes / denom) * CIRC;
list.push({
id: category.id,
stroke: category.stroke,
drawn: length > 6 ? length - 3 : length,
offset: cursor,
});
cursor += length;
}
return list;
}, [denom, excluded, sizeOf]);
const pickedBytes = useMemo(
() => RECLAIM.filter((r) => picked.has(r.id) && !cleared.has(r.id)).reduce((sum, r) => sum + r.bytes, 0),
[cleared, picked],
);
const selectPlan = useCallback(
(next: Plan) => {
setPlanId(next.id);
setStatus(`${next.name} plan selected · ${next.quotaLabel} quota, ${next.seats}`);
},
[],
);
const onPlanKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
const index = PLANS.findIndex((p) => p.id === planId);
let next: number | null = null;
if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % PLANS.length;
else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index - 1 + PLANS.length) % PLANS.length;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = PLANS.length - 1;
if (next === null) return;
event.preventDefault();
selectPlan(PLANS[next]);
planRefs.current[next]?.focus();
},
[planId, selectPlan],
);
const toggleCategory = useCallback((category: Category) => {
setExcluded((prev) => {
const next = new Set(prev);
if (next.has(category.id)) {
next.delete(category.id);
setStatus(`${category.label} included in the usage total`);
} else {
next.add(category.id);
setStatus(`${category.label} excluded from the usage total`);
}
return next;
});
}, []);
const togglePick = useCallback((item: ReclaimItem) => {
setPicked((prev) => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id);
else next.add(item.id);
return next;
});
}, []);
const freeUp = useCallback(() => {
if (pickedBytes <= 0) return;
const freed = formatSize(pickedBytes);
setCleared((prev) => {
const next = new Set(prev);
picked.forEach((id) => next.add(id));
return next;
});
setPicked(new Set());
setStatus(`${freed} freed up — cleared items are recoverable for 7 days`);
}, [picked, pickedBytes]);
const restore = useCallback(() => {
setCleared(new Set());
setPicked(new Set());
setStatus("Restored all cleared items to the usage total");
}, []);
return (
<section className="relative w-full bg-slate-50 px-4 py-16 text-slate-900 sm:px-6 sm:py-20 dark:bg-slate-950 dark:text-slate-100">
<style>{`
@keyframes fmstor-ring-in {
from { opacity: 0; transform: rotate(-18deg) scale(.92); }
to { opacity: 1; transform: none; }
}
@keyframes fmstor-alert-pulse {
0%, 100% { opacity: 1; }
50% { opacity: .45; }
}
.fmstor-ring { animation: fmstor-ring-in .7s cubic-bezier(.22,1,.36,1) backwards; transform-origin: 50% 50%; }
.fmstor-seg {
transition: stroke-dasharray .6s cubic-bezier(.22,1,.36,1), stroke-dashoffset .6s cubic-bezier(.22,1,.36,1), opacity .2s ease, stroke-width .2s ease;
}
.fmstor-alert-dot { animation: fmstor-alert-pulse 1.8s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.fmstor-ring, .fmstor-alert-dot { animation: none !important; }
.fmstor-seg { transition: opacity .2s ease !important; }
}
`}</style>
<div className="mx-auto w-full max-w-5xl">
<header className="mb-8 flex flex-col gap-4 border-b border-slate-200 pb-6 sm:flex-row sm:items-end sm:justify-between dark:border-slate-800">
<div>
<p className="mb-2 inline-flex items-center gap-1.5 rounded-full bg-slate-200/70 px-2.5 py-1 text-[11px] font-semibold tracking-wide text-slate-600 uppercase dark:bg-slate-800 dark:text-slate-300">
<DriveIcon />
Atlas Studio · Shared workspace
</p>
<h2 className="text-2xl font-semibold tracking-tight sm:text-3xl">Storage usage</h2>
<p className="mt-1.5 max-w-md text-sm text-slate-600 dark:text-slate-400">
Last scanned 17 July, 06:40 IST. Switch plans to model your headroom, or clear the safe-to-delete buckets.
</p>
</div>
<dl className="flex shrink-0 gap-6">
<div>
<dt className="text-[11px] font-medium tracking-wide text-slate-500 uppercase dark:text-slate-400">Files</dt>
<dd className="mt-0.5 text-lg font-semibold tabular-nums">41,328</dd>
</div>
<div>
<dt className="text-[11px] font-medium tracking-wide text-slate-500 uppercase dark:text-slate-400">Growth / mo</dt>
<dd className="mt-0.5 text-lg font-semibold tabular-nums text-amber-600 dark:text-amber-400">+84 GB</dd>
</div>
</dl>
</header>
<div className="grid gap-5 lg:grid-cols-[minmax(0,340px)_minmax(0,1fr)]">
<motion.div
initial={reduce ? false : { opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduce ? 0 : 0.45, ease: EASE }}
className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900"
>
<div className="relative mx-auto h-[200px] w-[200px]">
<svg viewBox="0 0 160 160" className="fmstor-ring h-full w-full" role="img" aria-label={`${formatPercent(percent)} percent of the ${plan.quotaLabel} ${plan.name} plan used`}>
<g transform="rotate(-90 80 80)">
<circle
cx="80"
cy="80"
r={RADIUS}
fill="none"
strokeWidth={13}
className={over ? "stroke-rose-200 dark:stroke-rose-500/25" : "stroke-slate-200 dark:stroke-slate-800"}
/>
{segments.map((segment) => (
<circle
key={segment.id}
cx="80"
cy="80"
r={RADIUS}
fill="none"
strokeLinecap="butt"
strokeWidth={highlight === segment.id ? 17 : 13}
strokeDasharray={`${segment.drawn} ${Math.max(CIRC - segment.drawn, 0)}`}
strokeDashoffset={-segment.offset}
className={`fmstor-seg ${segment.stroke}`}
style={{ opacity: highlight && highlight !== segment.id ? 0.2 : 1 }}
/>
))}
</g>
</svg>
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center">
<span className={`text-4xl font-semibold tracking-tight tabular-nums ${over ? "text-rose-600 dark:text-rose-400" : ""}`}>
{formatPercent(percent)}
<span className="text-xl">%</span>
</span>
<span className="mt-1 text-sm font-medium tabular-nums">{formatSize(used)}</span>
<span className="text-xs text-slate-500 dark:text-slate-400">of {plan.quotaLabel}</span>
</div>
</div>
{over ? (
<p className="mt-5 flex items-start gap-2 rounded-xl bg-rose-50 px-3 py-2.5 text-xs leading-relaxed text-rose-700 dark:bg-rose-500/10 dark:text-rose-300">
<span className="fmstor-alert-dot mt-0.5 shrink-0">
<AlertIcon />
</span>
<span>
Over the {plan.quotaLabel} quota by <strong className="font-semibold tabular-nums">{formatSize(used - plan.quota)}</strong>. New uploads and sync would be paused on this plan.
</span>
</p>
) : (
<p className="mt-5 rounded-xl bg-slate-100 px-3 py-2.5 text-xs leading-relaxed text-slate-600 dark:bg-slate-800/70 dark:text-slate-300">
<strong className="font-semibold tabular-nums text-slate-900 dark:text-slate-100">{formatSize(plan.quota - used)}</strong> free — about{" "}
<strong className="font-semibold tabular-nums text-slate-900 dark:text-slate-100">
{Math.max(Math.floor((plan.quota - used) / (84 * GB)), 0)} months
</strong>{" "}
at your current growth rate.
</p>
)}
<div className="mt-6">
<p id="fmstor-plan-label" className="mb-2 text-xs font-semibold tracking-wide text-slate-500 uppercase dark:text-slate-400">
Model a plan
</p>
<div role="radiogroup" aria-labelledby="fmstor-plan-label" onKeyDown={onPlanKeyDown} className="grid grid-cols-3 gap-2">
{PLANS.map((item, index) => {
const active = item.id === planId;
return (
<button
key={item.id}
ref={(node) => {
planRefs.current[index] = node;
}}
type="button"
role="radio"
aria-checked={active}
tabIndex={active ? 0 : -1}
onClick={() => selectPlan(item)}
className={`rounded-xl border px-2 py-2.5 text-center transition-colors focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:outline-none dark:focus-visible:ring-offset-slate-900 ${
active
? "border-indigo-500 bg-indigo-50 text-indigo-700 dark:border-indigo-400 dark:bg-indigo-500/15 dark:text-indigo-200"
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:bg-slate-800"
}`}
>
<span className="block text-xs font-semibold">{item.name}</span>
<span className="mt-0.5 block text-[11px] tabular-nums opacity-80">{item.quotaLabel}</span>
</button>
);
})}
</div>
</div>
</motion.div>
<motion.div
initial={reduce ? false : { opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduce ? 0 : 0.45, delay: reduce ? 0 : 0.08, ease: EASE }}
className="flex flex-col gap-5"
>
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6 dark:border-slate-800 dark:bg-slate-900">
<div className="mb-4 flex items-baseline justify-between gap-3">
<h3 className="text-sm font-semibold">Breakdown by type</h3>
<p className="text-xs text-slate-500 dark:text-slate-400">Toggle a row to include or exclude it</p>
</div>
<ul className="flex flex-col gap-1">
{CATEGORIES.map((category) => {
const bytes = sizeOf(category);
const active = !excluded.has(category.id);
const share = active ? (bytes / denom) * 100 : 0;
return (
<li key={category.id}>
<button
type="button"
role="switch"
aria-checked={active}
aria-label={`${category.label}, ${formatSize(bytes)}. ${active ? "Included in" : "Excluded from"} the usage total.`}
onClick={() => toggleCategory(category)}
onMouseEnter={() => setHighlight(category.id)}
onMouseLeave={() => setHighlight(null)}
onFocus={() => setHighlight(category.id)}
onBlur={() => setHighlight(null)}
className="w-full rounded-xl px-2.5 py-2.5 text-left transition-colors hover:bg-slate-50 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:outline-none dark:hover:bg-slate-800/60"
>
<span className="flex items-center gap-2.5">
<span
aria-hidden="true"
className={`h-2.5 w-2.5 shrink-0 rounded-full transition-opacity ${category.dot} ${active ? "opacity-100" : "opacity-25"}`}
/>
<span className={`min-w-0 flex-1 transition-opacity ${active ? "opacity-100" : "opacity-45"}`}>
<span className="block truncate text-sm font-medium">{category.label}</span>
<span className="block truncate text-xs text-slate-500 dark:text-slate-400">{category.hint}</span>
</span>
<span className={`shrink-0 text-right transition-opacity ${active ? "opacity-100" : "opacity-45"}`}>
<span className="block text-sm font-semibold tabular-nums">{formatSize(bytes)}</span>
<span className="block text-xs tabular-nums text-slate-500 dark:text-slate-400">{formatPercent(share)}%</span>
</span>
</span>
<span aria-hidden="true" className="mt-2 block h-1.5 w-full overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800">
<motion.span
className={`block h-full rounded-full ${category.bar}`}
initial={false}
animate={{ width: `${Math.max(share, 0)}%` }}
transition={{ duration: reduce ? 0 : 0.5, ease: EASE }}
/>
</span>
</button>
</li>
);
})}
</ul>
</div>
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6 dark:border-slate-800 dark:bg-slate-900">
<div className="mb-3 flex items-baseline justify-between gap-3">
<h3 className="text-sm font-semibold">Reclaim space</h3>
{cleared.size > 0 ? (
<button
type="button"
onClick={restore}
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs font-semibold text-indigo-600 transition-colors hover:bg-indigo-50 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:outline-none dark:text-indigo-300 dark:hover:bg-indigo-500/15"
>
<UndoIcon />
Undo
</button>
) : null}
</div>
<ul className="flex flex-col divide-y divide-slate-100 dark:divide-slate-800">
{RECLAIM.map((item) => {
const done = cleared.has(item.id);
return (
<li key={item.id}>
<label
className={`flex cursor-pointer items-center gap-3 py-2.5 ${done ? "cursor-default opacity-45" : ""}`}
>
<input
type="checkbox"
checked={picked.has(item.id) && !done}
disabled={done}
onChange={() => togglePick(item)}
className="h-4 w-4 shrink-0 rounded border-slate-300 accent-indigo-600 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:outline-none dark:border-slate-600 dark:focus-visible:ring-offset-slate-900"
/>
<span className="min-w-0 flex-1">
<span className={`block truncate text-sm font-medium ${done ? "line-through" : ""}`}>{item.label}</span>
<span className="block truncate text-xs text-slate-500 dark:text-slate-400">
{done ? "Cleared · recoverable for 7 days" : item.hint}
</span>
</span>
<span className="shrink-0 text-sm font-semibold tabular-nums">{formatSize(item.bytes)}</span>
</label>
</li>
);
})}
</ul>
<button
type="button"
onClick={freeUp}
disabled={pickedBytes <= 0}
className="mt-4 inline-flex w-full items-center justify-center gap-2 rounded-xl bg-slate-900 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-slate-700 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:bg-slate-200 disabled:text-slate-400 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-white dark:focus-visible:ring-offset-slate-900 dark:disabled:bg-slate-800 dark:disabled:text-slate-500"
>
<SweepIcon />
{pickedBytes > 0 ? `Free up ${formatSize(pickedBytes)}` : "Select items to free up"}
</button>
</div>
</motion.div>
</div>
<p
role="status"
aria-live="polite"
className="mt-5 rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-xs text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400"
>
{status}
</p>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
File Manager List
Originalfile manager list view

File Manager Grid
Originalfile manager grid view

File Manager Upload
Originalupload dropzone with progress

File Manager Breadcrumb
Originalfile path breadcrumb with toolbar

File Manager Tree
Originalfolder tree sidebar

File Manager Preview
Originalfile preview panel

File Manager Recent
Originalrecent files list

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

App Preview Hero
OriginalA centred hero that pairs headline copy with a realistic product dashboard mock built entirely from markup, complete with browser chrome and a floating notification card.

Waitlist Capture Hero
OriginalA dark, focused hero with an inline email capture form and avatar social proof, ready for pre-launch waitlists.

