MediaLibrary
Preview
Basic
Loading…
Preview
Code
ts
import MediaLibrary from '@/components/media/MediaLibrary';src/components/media/MediaLibrary.tsx
AI prompt
text
Build a searchable media library picker component (grid and list views, drag-and-drop upload) in React + TypeScript + Tailwind CSS.
## Look
- Root `flex flex-col gap-3`.
- Toolbar `flex flex-wrap items-center gap-2`:
- Search: `relative min-w-48 flex-1`, a 14px Search icon at `left-2.5`, and a text input with `pl-8` and placeholder "Search media…".
- View toggle: `flex items-center gap-0.5 rounded-lg border border-slate-200 dark:border-slate-700 p-0.5` holding two `rounded-md p-1.5` icon buttons (Grid2x2, List; 14px). The active one is `bg-indigo-600 text-white`; the other is `text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800`.
- A primary "Upload" button with an Upload icon, only when `onUpload` is passed. Make it a `relative` <label> around a visually hidden file input; `relative` keeps the sr-only input from escaping and stretching the page.
- Drop zone around the items: `min-h-0 flex-1 rounded-xl border border-dashed p-3 transition-colors`, `border-slate-200 dark:border-slate-700`. While a file is dragged over it: `border-indigo-400 bg-indigo-50/50 dark:bg-indigo-500/10`.
- Grid view: `grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3` of tile buttons, `relative rounded-lg border p-2 text-left`.
- Unselected: `border-slate-200 hover:border-slate-300 dark:border-slate-700 dark:hover:border-slate-600`.
- Selected: `border-indigo-500 bg-indigo-50/60 dark:bg-indigo-500/10`, plus a 16px indigo-600 circle at `right-1.5 top-1.5` holding a white 10px Check (strokeWidth 3).
- Contents: a square thumbnail (`aspect-square w-full rounded-md object-cover bg-slate-100 dark:bg-slate-800`), then the name (`mt-1.5 truncate text-[11px] font-medium`), then the size (10px slate-400).
- List view: `divide-y divide-slate-100 dark:divide-slate-800` of row buttons, `flex w-full items-center gap-3 rounded-md px-2 py-1.5`.
- Selected: `bg-indigo-50/60 dark:bg-indigo-500/10`. Otherwise: `hover:bg-slate-50 dark:hover:bg-slate-800/50`.
- Contents: a 36px thumbnail, the name (text-xs font-medium, truncating), a meta line (10px slate-400, "image · 471 KB · 2026-08-28"), and a 14px indigo-600 Check when selected.
- Thumbnails use `thumbnailUrl`, else `url` for images. If there is no source, or the image fails to load, show a slate-100 / dark slate-800 box with a 20px slate-400 icon for the kind (image/other: File, video: Film, audio: Music, document: FileText). Never show a broken-image glyph: dead URLs are common here, and a grid of broken images reads as a broken component.
- Empty: centred `py-16` text-sm font-medium, "Nothing matches that search" or "No media yet". When upload is enabled, add an ⓘ tooltip: "Drop files here, or use Upload."
## Behaviour
- Search filters by name, case-insensitive. The view (grid by default) is internal state.
- Selection is CONTROLLED and always a `string[]`, even when `multiple` is false.
- Multiple: a click toggles the item.
- Single: a click gives `[id]`, or `[]` when that item is clicked again.
- Without `onSelectedChange` the library is read-only.
- Upload: through the file input (`multiple` follows the prop; `accept` is passed through), or by dropping files on the zone (preventDefault on dragover). Both call `onUpload(FileList)`.
- Sizes: base 1024, units B / KB / MB / GB, one decimal under 10 (except bytes), otherwise rounded.
## API
- `MediaItem = { id: string; name: string; kind: 'image' | 'video' | 'audio' | 'document' | 'other'; size?: number; url?: string; thumbnailUrl?: string; uploadedAt?: string }` (`size` is in bytes)
- `items`, `selected?` = [], `onSelectedChange?(ids)`, `multiple?` = true, `onUpload?(files)` (omit it to hide upload entirely), `accept?`, `className?`
## Accessibility
- Tiles and rows: `aria-pressed`. View buttons: `aria-label` "grid view" / "list view", with `aria-pressed`.
## Demo
Eight items: hero-banner.png, onboarding.mp4, terms-v3.pdf, launch-jingle.mp3, avatar-ada.png, export.csv, screenshot-01.png, walkthrough.mov. Use realistic sizes, from 5 KB to 41 MB, and August 2026 upload dates. Pre-select the first. `onUpload` prepends the dropped files as new items, with `URL.createObjectURL` for their url.
## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.Source
tsx
'use client';
import { useMemo, useState } from 'react';
import { Check, File, FileText, Film, Grid2x2, List, Music, Search, Upload } from 'lucide-react';
import { cn } from '@/lib/cn';
import EmptyState from '@/components/layout/EmptyState';
export type MediaItem = {
id: string;
name: string;
/** Drives the placeholder icon and the preview treatment. */
kind: 'image' | 'video' | 'audio' | 'document' | 'other';
/** Bytes. Rendered human-readable. */
size?: number;
url?: string;
thumbnailUrl?: string;
uploadedAt?: string;
};
const ICON = {
image: File,
video: Film,
audio: Music,
document: FileText,
other: File,
} as const;
function humanSize(bytes?: number) {
if (bytes == null) return '';
const units = ['B', 'KB', 'MB', 'GB'];
let n = bytes;
let u = 0;
while (n >= 1024 && u < units.length - 1) {
n /= 1024;
u++;
}
return `${n < 10 && u > 0 ? n.toFixed(1) : Math.round(n)} ${units[u]}`;
}
/**
* A picker for files that already exist, with an upload affordance beside them.
*
* Selection is CONTROLLED and always an array, even when `multiple` is false —
* one shape for the caller to handle rather than a union that every call site
* has to narrow.
*
* Thumbnails fall back to a kind icon rather than a broken-image glyph: a media
* library is exactly where a dead URL is most likely, and a grid of broken
* images reads as a broken component.
*/
export default function MediaLibrary({
items,
selected = [],
onSelectedChange,
multiple = true,
onUpload,
accept,
className,
}: {
items: MediaItem[];
selected?: string[];
onSelectedChange?: (next: string[]) => void;
multiple?: boolean;
/** Omit to hide the upload control entirely. */
onUpload?: (files: FileList) => void;
accept?: string;
className?: string;
}) {
const [query, setQuery] = useState('');
const [view, setView] = useState<'grid' | 'list'>('grid');
const [dragging, setDragging] = useState(false);
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
return q ? items.filter((i) => i.name.toLowerCase().includes(q)) : items;
}, [items, query]);
const toggle = (id: string) => {
if (!onSelectedChange) return;
if (!multiple) return onSelectedChange(selected.includes(id) ? [] : [id]);
onSelectedChange(selected.includes(id) ? selected.filter((s) => s !== id) : [...selected, id]);
};
const Thumb = ({ item, size }: { item: MediaItem; size: 'sm' | 'lg' }) => {
const [failed, setFailed] = useState(false);
const src = item.thumbnailUrl ?? (item.kind === 'image' ? item.url : undefined);
const Icon = ICON[item.kind];
const box = size === 'lg' ? 'aspect-square w-full' : 'h-9 w-9 shrink-0';
if (src && !failed) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt=""
onError={() => setFailed(true)}
className={cn(box, 'rounded-md object-cover bg-slate-100 dark:bg-slate-800')}
/>
);
}
return (
<div className={cn(box, 'grid place-items-center rounded-md bg-slate-100 dark:bg-slate-800')}>
<Icon className="h-5 w-5 text-slate-400" />
</div>
);
};
return (
<div className={cn('flex flex-col gap-3', className)}>
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-48 flex-1">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search media…"
className="field-input pl-8"
/>
</div>
<div className="flex items-center gap-0.5 rounded-lg border border-slate-200 p-0.5 dark:border-slate-700">
{([['grid', Grid2x2], ['list', List]] as const).map(([v, Icon]) => (
<button
key={v}
onClick={() => setView(v)}
aria-label={`${v} view`}
aria-pressed={view === v}
className={cn(
'rounded-md p-1.5',
view === v
? 'bg-indigo-600 text-white'
: 'text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800',
)}
>
<Icon className="h-3.5 w-3.5" />
</button>
))}
</div>
{onUpload && (
// `relative` anchors the input: `sr-only` is `position: absolute`, and
// with no positioned ancestor it escapes the scrolling `<main>` and
// stretches the document itself — a second, page-length scrollbar.
<label className="btn-primary relative cursor-pointer">
<Upload className="h-3.5 w-3.5" />
Upload
<input
type="file"
multiple={multiple}
accept={accept}
className="sr-only"
onChange={(e) => e.target.files?.length && onUpload(e.target.files)}
/>
</label>
)}
</div>
<div
onDragOver={onUpload ? (e) => { e.preventDefault(); setDragging(true); } : undefined}
onDragLeave={onUpload ? () => setDragging(false) : undefined}
onDrop={
onUpload
? (e) => {
e.preventDefault();
setDragging(false);
if (e.dataTransfer.files.length) onUpload(e.dataTransfer.files);
}
: undefined
}
className={cn(
'min-h-0 flex-1 rounded-xl border border-dashed p-3 transition-colors',
dragging
? 'border-indigo-400 bg-indigo-50/50 dark:bg-indigo-500/10'
: 'border-slate-200 dark:border-slate-700',
)}
>
{visible.length === 0 ? (
<EmptyState
title={query ? 'Nothing matches that search' : 'No media yet'}
hint={onUpload ? 'Drop files here, or use Upload.' : undefined}
/>
) : view === 'grid' ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{visible.map((item) => {
const on = selected.includes(item.id);
return (
<button
key={item.id}
onClick={() => toggle(item.id)}
aria-pressed={on}
className={cn(
'group relative rounded-lg border p-2 text-left transition-colors',
on
? 'border-indigo-500 bg-indigo-50/60 dark:bg-indigo-500/10'
: 'border-slate-200 hover:border-slate-300 dark:border-slate-700 dark:hover:border-slate-600',
)}
>
<Thumb item={item} size="lg" />
<p className="mt-1.5 truncate text-[11px] font-medium text-slate-700 dark:text-slate-200">
{item.name}
</p>
<p className="text-[10px] text-slate-400">{humanSize(item.size)}</p>
{on && (
<span className="absolute right-1.5 top-1.5 grid h-4 w-4 place-items-center rounded-full bg-indigo-600">
<Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />
</span>
)}
</button>
);
})}
</div>
) : (
<ul className="divide-y divide-slate-100 dark:divide-slate-800">
{visible.map((item) => {
const on = selected.includes(item.id);
return (
<li key={item.id}>
<button
onClick={() => toggle(item.id)}
aria-pressed={on}
className={cn(
'flex w-full items-center gap-3 rounded-md px-2 py-1.5 text-left',
on ? 'bg-indigo-50/60 dark:bg-indigo-500/10' : 'hover:bg-slate-50 dark:hover:bg-slate-800/50',
)}
>
<Thumb item={item} size="sm" />
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-medium text-slate-700 dark:text-slate-200">
{item.name}
</span>
<span className="block text-[10px] text-slate-400">
{item.kind} · {humanSize(item.size)}
{item.uploadedAt ? ` · ${item.uploadedAt}` : ''}
</span>
</span>
{on && <Check className="h-3.5 w-3.5 shrink-0 text-indigo-600" strokeWidth={3} />}
</button>
</li>
);
})}
</ul>
)}
</div>
</div>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items* | MediaItem[] | — | |
selected | string[] | [] | |
onSelectedChange | (next: string[]) => void | — | |
multiple | boolean | true | |
onUpload | (files: FileList) => void | — | Omit to hide the upload control entirely. |
accept | string | — | |
className | string | — |