Q3 report.pdf 2.4 MB · Edited 2h ago Budget 2027.xlsx 860 KB · Edited yesterday Hero banner.png 1.1 MB · Edited Sep 12
import { FileTextIcon, ImageIcon, SheetIcon } from "lucide-react" ;
import {
GridList,
GridListItem,
GridListItemDescription,
GridListItemLabel,
} from "@/components/ui/grid-list" ;
const files = [
{
id: "1" ,
name: "Q3 report.pdf" ,
meta: "2.4 MB · Edited 2h ago" ,
icon: FileTextIcon,
},
{
id: "2" ,
name: "Budget 2027.xlsx" ,
meta: "860 KB · Edited yesterday" ,
icon: SheetIcon,
},
{
id: "3" ,
name: "Hero banner.png" ,
meta: "1.1 MB · Edited Sep 12" ,
icon: ImageIcon,
},
];
export default function GridListDemo () {
return (
< GridList
aria-label = "Files"
items = {files}
selectionMode = "multiple"
className = "w-80"
>
{( file ) => (
< GridListItem textValue = {file.name}>
< span className = "flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground" >
< file.icon className = "size-4" />
</ span >
< span className = "flex min-w-0 flex-col" >
< GridListItemLabel >{file.name}</ GridListItemLabel >
< GridListItemDescription >{file.meta}</ GridListItemDescription >
</ span >
</ GridListItem >
)}
</ GridList >
);
}
CLI Manual
$ pnpm dlx shadcn@latest add @desyne/grid-list
The CLI installs dependencies and any other components this one uses.
import {
GridList,
GridListItem,
GridListItemDescription,
GridListItemLabel,
} from "@/components/ui/grid-list" ;
< GridList aria-label = "Files" selectionMode = "multiple" >
< GridListItem id = "report" >Q3 report.pdf</ GridListItem >
< GridListItem id = "budget" >Budget 2027.xlsx</ GridListItem >
</ GridList >
Rows can contain controls
Unlike List Box , grid list rows are role="row"
and may contain buttons, menus, switches and links. Arrow keys move between
rows, and ← / → move into a row's controls. Selection
checkboxes and drag handles are added for you.
Grid List — a list of objects users act on: files, members, notifications, tasks, cards.
List Box — choosing values from plain options, with no controls inside rows.
Table — multiple columns of comparable data, sorting and column headers.
Menu — a transient list of commands.
< GridList > { /* role="grid" */ }
< GridListItem > { /* role="row" */ }
{ /* drag handle, when dragAndDropHooks is set */ }
{ /* selection checkbox, when selectionMode is set */ }
< Icon />
< GridListItemLabel />
< GridListItemDescription />
< Button /> { /* any interactive content */ }
</ GridListItem >
</ GridList >
Part Renders Notes GridList<div role="grid">Scrollable list. variant sets the frame. Shows renderEmptyState when empty. GridListItem<div role="row"> › <div role="gridcell">A row. Plain-string children become its textValue. Drag handle <button slot="drag">Grip icon, rendered automatically when the list has dragAndDropHooks. Selection checkbox CheckboxRendered automatically when selectionMode is set and selectionBehavior is toggle. GridListItemLabel<span>Bold, truncated primary text. GridListItemDescription<span slot="description">Muted secondary text, linked as the row's description.
bordered (default) frames the list as a card, separated draws a divider between rows, and plain has no frame for embedding in your own container.
import { GridList, GridListItem } from "@/components/ui/grid-list" ;
const items = [ "Inbox" , "Drafts" , "Sent" ];
export default function GridListVariants () {
return (
< div className = "grid w-full max-w-xl gap-4 sm:grid-cols-3" >
{([ "bordered" , "separated" , "plain" ] as const ). map (( variant ) => (
< div key = {variant} className = "flex flex-col gap-2" >
< span className = "text-muted-foreground text-xs capitalize" >
{variant}
</ span >
< GridList
aria-label = {variant}
variant = {variant}
selectionMode = "single"
defaultSelectedKeys = {[ "Inbox" ]}
>
{items. map (( i ) => (
< GridListItem key = {i} id = {i}>
{i}
</ GridListItem >
))}
</ GridList >
</ div >
))}
</ div >
);
}
selectionMode="single" adds a checkbox that behaves like a radio. disallowEmptySelection keeps one row selected.
Visa ending 4242 Expires 08/28 Amex ending 1005 Expires 02/27 Business checking ACH · ending 6789 Account credit $120.00 available
import { CreditCardIcon, LandmarkIcon, WalletIcon } from "lucide-react" ;
import {
GridList,
GridListItem,
GridListItemDescription,
GridListItemLabel,
} from "@/components/ui/grid-list" ;
const methods = [
{
id: "visa" ,
icon: CreditCardIcon,
name: "Visa ending 4242" ,
meta: "Expires 08/28" ,
},
{
id: "amex" ,
icon: CreditCardIcon,
name: "Amex ending 1005" ,
meta: "Expires 02/27" ,
},
{
id: "bank" ,
icon: LandmarkIcon,
name: "Business checking" ,
meta: "ACH · ending 6789" ,
},
{
id: "wallet" ,
icon: WalletIcon,
name: "Account credit" ,
meta: "$120.00 available" ,
},
];
export default function GridListSingleSelection () {
return (
< GridList
aria-label = "Payment method"
items = {methods}
selectionMode = "single"
disallowEmptySelection
defaultSelectedKeys = {[ "visa" ]}
className = "w-full max-w-80"
>
{( m ) => (
< GridListItem textValue = {m.name}>
< m.icon className = "size-4 shrink-0 text-muted-foreground" />
< span className = "flex min-w-0 flex-col" >
< GridListItemLabel >{m.name}</ GridListItemLabel >
< GridListItemDescription >{m.meta}</ GridListItemDescription >
</ span >
</ GridListItem >
)}
</ GridList >
);
}
selectedKeys and onSelectionChange own the selection. Handle both a Set of keys and "all" (after ⌘ /Ctrl +A or the select-all shortcut).
Slack Post alerts to #incidents GitHub Link commits and pull requests Linear Create issues from alerts Datadog Import metrics and monitors 2 of 5 enabled Disable all
import { useState } from "react" ;
import type { Selection } from "react-aria-components" ;
import { Button } from "@/components/ui/button" ;
import {
GridList,
GridListItem,
GridListItemDescription,
GridListItemLabel,
} from "@/components/ui/grid-list" ;
const integrations = [
{ id: "slack" , name: "Slack" , detail: "Post alerts to #incidents" },
{ id: "github" , name: "GitHub" , detail: "Link commits and pull requests" },
{ id: "linear" , name: "Linear" , detail: "Create issues from alerts" },
{ id: "pagerduty" , name: "PagerDuty" , detail: "Page the on-call engineer" },
{ id: "datadog" , name: "Datadog" , detail: "Import metrics and monitors" },
];
export default function GridListControlled () {
const [ selected , setSelected ] = useState < Selection >(
new Set ([ "slack" , "github" ]),
);
const count = selected === "all" ? integrations. length : selected.size;
return (
< div className = "flex w-full max-w-80 flex-col gap-2" >
< GridList
aria-label = "Integrations"
items = {integrations}
selectionMode = "multiple"
selectedKeys = {selected}
onSelectionChange = {setSelected}
>
{( item ) => (
< GridListItem textValue = {item.name}>
< span className = "flex min-w-0 flex-col" >
< GridListItemLabel >{item.name}</ GridListItemLabel >
< GridListItemDescription >{item.detail}</ GridListItemDescription >
</ span >
</ GridListItem >
)}
</ GridList >
< div className = "flex items-center justify-between" >
< span className = "text-muted-foreground text-xs" >
{count} of {integrations. length } enabled
</ span >
< Button
size = "xs"
variant = "ghost"
onPress = {() => setSelected ( new Set ())}
>
Disable all
</ Button >
</ div >
</ div >
);
}
selectionBehavior="replace" hides the checkboxes and selects like a file browser: a click selects one row, ⌘ /Ctrl or Shift add more.
No checkboxes: click to select, ⌘/Ctrl or Shift to select more.
import { FolderIcon } from "lucide-react" ;
import { GridList, GridListItem } from "@/components/ui/grid-list" ;
const folders = [
"Contracts" ,
"Invoices" ,
"Receipts" ,
"Tax returns" ,
"Payroll" ,
"Archive" ,
];
export default function GridListSelectionBehavior () {
return (
< div className = "flex w-full max-w-72 flex-col gap-2" >
< GridList
aria-label = "Folders"
selectionMode = "multiple"
selectionBehavior = "replace"
defaultSelectedKeys = {[ "Invoices" ]}
>
{folders. map (( name ) => (
< GridListItem key = {name} id = {name} textValue = {name}>
< FolderIcon className = "size-4 text-muted-foreground" />
{name}
</ GridListItem >
))}
</ GridList >
< p className = "text-muted-foreground text-xs" >
No checkboxes: click to select, ⌘/Ctrl or Shift to select more.
</ p >
</ div >
);
}
disabledKeys disables rows. With disabledBehavior="selection" they can't be selected but stay focusable, so keyboard and screen reader users can still reach them and their contents. The default all disables every interaction.
Ana Souza ana@northwind.io Owner Kenji Watanabe kenji@northwind.io Sam Okafor sam@northwind.io Inès Laurent ines@northwind.io
import { Badge } from "@/components/ui/badge" ;
import {
GridList,
GridListItem,
GridListItemDescription,
GridListItemLabel,
} from "@/components/ui/grid-list" ;
const seats = [
{ id: "ana" , name: "Ana Souza" , email: "ana@northwind.io" , owner: true },
{
id: "kenji" ,
name: "Kenji Watanabe" ,
email: "kenji@northwind.io" ,
owner: false ,
},
{ id: "sam" , name: "Sam Okafor" , email: "sam@northwind.io" , owner: false },
{
id: "ines" ,
name: "Inès Laurent" ,
email: "ines@northwind.io" ,
owner: false ,
},
];
export default function GridListDisabled () {
return (
< GridList
aria-label = "Seats to remove"
items = {seats}
selectionMode = "multiple"
disabledKeys = {[ "ana" ]}
disabledBehavior = "selection"
className = "w-full max-w-80"
>
{( s ) => (
< GridListItem textValue = {s.name}>
< span className = "flex min-w-0 flex-1 flex-col" >
< GridListItemLabel >{s.name}</ GridListItemLabel >
< GridListItemDescription >{s.email}</ GridListItemDescription >
</ span >
{s.owner && < Badge size = "sm" >Owner</ Badge >}
</ GridListItem >
)}
</ GridList >
);
}
onAction fires when a row is clicked or Enter is pressed. Buttons and menus inside the row handle their own presses without triggering the row action.
Onboarding flow Edited 2h ago Pricing page redesign Edited yesterday
import {
CopyIcon,
MoreHorizontalIcon,
PencilIcon,
StarIcon,
TrashIcon,
} from "lucide-react" ;
import { useListData } from "react-aria-components" ;
import { Button } from "@/components/ui/button" ;
import {
GridList,
GridListItem,
GridListItemDescription,
GridListItemLabel,
} from "@/components/ui/grid-list" ;
import { MenuContent, MenuItem, MenuTrigger } from "@/components/ui/menu" ;
export default function GridListActions () {
const list = useListData ({
initialItems: [
{
id: "1" ,
name: "Onboarding flow" ,
updated: "Edited 2h ago" ,
starred: true ,
},
{
id: "2" ,
name: "Pricing page redesign" ,
updated: "Edited yesterday" ,
starred: false ,
},
{ id: "3" , name: "Mobile nav" , updated: "Edited Sep 20" , starred: false },
],
});
return (
< GridList
aria-label = "Designs"
items = {list.items}
onAction = {( key ) => {
const item = list. getItem (key);
if (item) list. update (key, { ... item, updated: "Opened just now" });
}}
className = "w-full max-w-96"
>
{( item ) => (
< GridListItem textValue = {item.name}>
< span className = "flex min-w-0 flex-1 flex-col" >
< GridListItemLabel >{item.name}</ GridListItemLabel >
< GridListItemDescription >{item.updated}</ GridListItemDescription >
</ span >
< Button
size = "icon-sm"
variant = "ghost"
aria-label = {item.starred ? "Unstar" : "Star" }
onPress = {() =>
list. update (item.id, { ... item, starred: ! item.starred })
}
>
< StarIcon
className = {item.starred ? "fill-warning text-warning" : "" }
/>
</ Button >
< MenuTrigger >
< Button size = "icon-sm" variant = "ghost" aria-label = "More actions" >
< MoreHorizontalIcon />
</ Button >
< MenuContent placement = "bottom end" >
< MenuItem textValue = "Rename" >
< PencilIcon /> Rename
</ MenuItem >
< MenuItem textValue = "Duplicate" >
< CopyIcon /> Duplicate
</ MenuItem >
< MenuItem
textValue = "Delete"
variant = "destructive"
onAction = {() => list. remove (item.id)}
>
< TrashIcon /> Delete
</ MenuItem >
</ MenuContent >
</ MenuTrigger >
</ GridListItem >
)}
</ GridList >
);
}
Give rows an href to make the whole row a link. They work with client-side routers through React Aria's RouterProvider.
Installation Add the library to a Next.js app Theming Customize tokens, colors and radius Components Browse every component
import { ChevronRightIcon } from "lucide-react" ;
import {
GridList,
GridListItem,
GridListItemDescription,
GridListItemLabel,
} from "@/components/ui/grid-list" ;
const guides = [
{
id: "install" ,
href: "/docs/installation" ,
title: "Installation" ,
detail: "Add the library to a Next.js app" ,
},
{
id: "theming" ,
href: "/docs/theming" ,
title: "Theming" ,
detail: "Customize tokens, colors and radius" ,
},
{
id: "components" ,
href: "/docs/components" ,
title: "Components" ,
detail: "Browse every component" ,
},
];
export default function GridListLinks () {
return (
< GridList
aria-label = "Guides"
items = {guides}
variant = "separated"
className = "w-full max-w-80"
>
{( g ) => (
< GridListItem
href = {g.href}
textValue = {g.title}
className = "cursor-pointer"
>
< span className = "flex min-w-0 flex-1 flex-col" >
< GridListItemLabel >{g.title}</ GridListItemLabel >
< GridListItemDescription >{g.detail}</ GridListItemDescription >
</ span >
< ChevronRightIcon className = "size-4 text-muted-foreground" />
</ GridListItem >
)}
</ GridList >
);
}
renderEmptyState renders when there are no items. The list gets data-empty and centered, muted text.
No saved searches
Save a search from the results page to get back to it quickly.
import { InboxIcon } from "lucide-react" ;
import { GridList } from "@/components/ui/grid-list" ;
export default function GridListEmpty () {
return (
< GridList
aria-label = "Saved searches"
className = "w-full max-w-80"
renderEmptyState = {() => (
< div className = "flex flex-col items-center gap-2" >
< InboxIcon className = "size-6 text-muted-foreground/60" />
< p className = "font-medium text-foreground" >No saved searches</ p >
< p >Save a search from the results page to get back to it quickly.</ p >
</ div >
)}
>
{[]}
</ GridList >
);
}
Pass dragAndDropHooks from useDragAndDrop and each row gets a drag handle. Update your data in onReorder, and use renderDropIndicator with React Aria's DropIndicator to show where items will land. Keyboard users focus the handle and press Enter to start dragging.
import {
DropIndicator,
useDragAndDrop,
useListData,
} from "react-aria-components" ;
import { GridList, GridListItem } from "@/components/ui/grid-list" ;
export default function GridListReorder () {
const list = useListData ({
initialItems: [
{ id: "1" , name: "Triage new alerts" },
{ id: "2" , name: "Review access requests" },
{ id: "3" , name: "Rotate API keys" },
{ id: "4" , name: "Update runbooks" },
{ id: "5" , name: "Plan the next game day" },
],
});
const { dragAndDropHooks } = useDragAndDrop ({
getItems : ( keys ) =>
[ ... keys]. map (( key ) => ({ "text/plain" : list. getItem (key)?.name ?? "" })),
onReorder ( e ) {
if (e.target.dropPosition === "before" ) {
list. moveBefore (e.target.key, e.keys);
} else if (e.target.dropPosition === "after" ) {
list. moveAfter (e.target.key, e.keys);
}
},
renderDropIndicator : ( target ) => (
< DropIndicator
target = {target}
className = "-my-px h-0.5 rounded-full data-drop-target:bg-brand"
/>
),
});
return (
< GridList
aria-label = "Priorities"
items = {list.items}
selectionMode = "multiple"
dragAndDropHooks = {dragAndDropHooks}
className = "w-full max-w-80"
>
{( item ) => < GridListItem >{item.name}</ GridListItem >}
</ GridList >
);
}
layout="grid" enables two-dimensional arrow key navigation. Combine it with variant="plain" and CSS grid classes to build a card picker; restyle rows with className.
Landing page Hero, features, pricing
import {
GridList,
GridListItem,
GridListItemDescription,
GridListItemLabel,
} from "@/components/ui/grid-list" ;
const templates = [
{ id: "blank" , name: "Blank" , meta: "Start from scratch" , tint: "bg-muted" },
{
id: "landing" ,
name: "Landing page" ,
meta: "Hero, features, pricing" ,
tint: "bg-brand/15" ,
},
{
id: "blog" ,
name: "Blog" ,
meta: "Posts and categories" ,
tint: "bg-info/15" ,
},
{
id: "docs" ,
name: "Docs" ,
meta: "Sidebar and search" ,
tint: "bg-success/15" ,
},
{
id: "store" ,
name: "Store" ,
meta: "Products and cart" ,
tint: "bg-warning/15" ,
},
{
id: "portfolio" ,
name: "Portfolio" ,
meta: "Case studies" ,
tint: "bg-destructive/10" ,
},
];
export default function GridListGridLayout () {
return (
< GridList
aria-label = "Templates"
items = {templates}
layout = "grid"
selectionMode = "single"
defaultSelectedKeys = {[ "landing" ]}
variant = "plain"
className = "grid w-full max-w-lg grid-cols-2 gap-3 sm:grid-cols-3"
>
{( t ) => (
< GridListItem
textValue = {t.name}
className = "flex-col items-stretch gap-2 rounded-lg border bg-card p-2 data-selected:border-brand data-selected:bg-card data-selected:text-foreground data-selected:ring-2 data-selected:ring-brand/30 [&_[slot=selection]]:absolute [&_[slot=selection]]:top-3 [&_[slot=selection]]:right-3"
>
< div className = { `aspect-4/3 rounded-md ${ t . tint }` } />
< span className = "flex min-w-0 flex-col px-1 pb-1" >
< GridListItemLabel >{t.name}</ GridListItemLabel >
< GridListItemDescription >{t.meta}</ GridListItemDescription >
</ span >
</ GridListItem >
)}
</ GridList >
);
}
Render the items in a Collection followed by GridListLoadMoreItem, which calls onLoadMore as it scrolls into view. useAsyncList handles cursors and loading state. Give the list a fixed height.
import {
Collection,
GridListLoadMoreItem,
useAsyncList,
} from "react-aria-components" ;
import { Avatar } from "@/components/ui/avatar" ;
import {
GridList,
GridListItem,
GridListItemDescription,
GridListItemLabel,
} from "@/components/ui/grid-list" ;
import { Spinner } from "@/components/ui/spinner" ;
const first = [
"Ana" ,
"Kenji" ,
"Sam" ,
"Inès" ,
"Maya" ,
"Leo" ,
"Priya" ,
"Omar" ,
"Lena" ,
"Diego" ,
"Yuki" ,
"Tom" ,
];
const last = [
"Souza" ,
"Watanabe" ,
"Okafor" ,
"Laurent" ,
"Patel" ,
"Fischer" ,
"Nair" ,
"Haddad" ,
];
const people = Array. from ({ length: 36 }, ( _ , i ) => {
const name = `${ first [ i % first . length ] } ${ last [( i * 5 ) % last . length ] }` ;
return {
id: String (i + 1 ),
name,
email: `${ name . toLowerCase (). replace ( / [ ^ a-z] + / g , "." ) }@acme.dev` ,
};
});
export default function GridListAsync () {
const list = useAsyncList <( typeof people)[ number ]>({
async load ({ cursor }) {
// Replace with a real request that returns the next page and cursor.
const start = cursor ? Number (cursor) : 0 ;
await new Promise (( resolve ) => setTimeout (resolve, 700 ));
const next = start + 8 ;
return {
items: people. slice (start, next),
cursor: next < people. length ? String (next) : undefined ,
};
},
});
return (
< GridList
aria-label = "Members"
selectionMode = "multiple"
className = "h-72 w-full max-w-80"
renderEmptyState = {() =>
list.isLoading ? (
< Spinner label = "Loading members" className = "mx-auto" />
) : (
"No members."
)
}
>
< Collection items = {list.items}>
{( p ) => (
< GridListItem textValue = {p.name}>
< Avatar
size = "sm"
colorful
alt = {p.name}
fallback = {p.name
. split ( " " )
. map (( n ) => n[ 0 ])
. join ( "" )}
/>
< span className = "flex min-w-0 flex-col" >
< GridListItemLabel >{p.name}</ GridListItemLabel >
< GridListItemDescription >{p.email}</ GridListItemDescription >
</ span >
</ GridListItem >
)}
</ Collection >
< GridListLoadMoreItem
onLoadMore = {list.loadMore}
isLoading = {list.loadingState === "loadingMore" }
className = "flex justify-center py-2"
>
< Spinner label = "Loading more members" />
</ GridListLoadMoreItem >
</ GridList >
);
}
A plain grid list inside a card, with a toolbar that switches to bulk actions while rows are selected.
5 files
Revenue forecast.xlsx 860 KB Churn analysis.xlsx 1.3 MB
import {
DownloadIcon,
FileTextIcon,
ImageIcon,
SheetIcon,
TrashIcon,
} from "lucide-react" ;
import { useState } from "react" ;
import { type Selection, useListData } from "react-aria-components" ;
import { Button } from "@/components/ui/button" ;
import {
GridList,
GridListItem,
GridListItemDescription,
GridListItemLabel,
} from "@/components/ui/grid-list" ;
const icons = { doc: FileTextIcon, sheet: SheetIcon, image: ImageIcon };
export default function GridListRecipeFileManager () {
const files = useListData ({
initialItems: [
{
id: "1" ,
name: "Q3 board deck.pdf" ,
size: "4.2 MB" ,
kind: "doc" as const ,
},
{
id: "2" ,
name: "Revenue forecast.xlsx" ,
size: "860 KB" ,
kind: "sheet" as const ,
},
{
id: "3" ,
name: "Team offsite.jpg" ,
size: "2.8 MB" ,
kind: "image" as const ,
},
{
id: "4" ,
name: "Hiring plan.pdf" ,
size: "310 KB" ,
kind: "doc" as const ,
},
{
id: "5" ,
name: "Churn analysis.xlsx" ,
size: "1.3 MB" ,
kind: "sheet" as const ,
},
],
});
const [ selected , setSelected ] = useState < Selection >( new Set ());
const count = selected === "all" ? files.items. length : selected.size;
const removeSelected = () => {
const keys =
selected === "all" ? files.items. map (( f ) => f.id) : [ ... selected];
files. remove ( ... keys);
setSelected ( new Set ());
};
return (
< div className = "flex w-full max-w-md flex-col overflow-hidden rounded-lg border bg-card shadow-xs" >
< div className = "flex h-11 items-center justify-between gap-2 border-b px-3" >
{count > 0 ? (
<>
< span className = "font-medium text-sm" >{count} selected</ span >
< div className = "flex gap-1" >
< Button size = "sm" variant = "ghost" >
< DownloadIcon /> Download
</ Button >
< Button
size = "sm"
variant = "ghost"
color = "danger"
onPress = {removeSelected}
>
< TrashIcon /> Delete
</ Button >
</ div >
</>
) : (
< span className = "text-muted-foreground text-sm" >
{files.items. length } files
</ span >
)}
</ div >
< GridList
aria-label = "Files"
items = {files.items}
variant = "plain"
selectionMode = "multiple"
selectedKeys = {selected}
onSelectionChange = {setSelected}
className = "max-h-72 p-1"
renderEmptyState = {() => "This folder is empty." }
>
{( file ) => {
const Icon = icons[file.kind];
return (
< GridListItem textValue = {file.name}>
< span className = "flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground" >
< Icon className = "size-4" />
</ span >
< span className = "flex min-w-0 flex-1 flex-col" >
< GridListItemLabel >{file.name}</ GridListItemLabel >
< GridListItemDescription >{file.size}</ GridListItemDescription >
</ span >
</ GridListItem >
);
}}
</ GridList >
</ div >
);
}
Three lists that share a custom drag type. onInsert and onRootDrop accept items from other columns, onReorder moves within a column, and onDragEnd removes moved items from the source.
To do 3
Audit onboarding emails Medium
Fix CSV export timeout High
Refresh pricing screenshots Low
In progress 1
SSO for enterprise workspaces High
Done 1
Migrate to the new billing API Medium
import {
DropIndicator,
type DropItem,
isTextDropItem,
type ListData,
useDragAndDrop,
useListData,
} from "react-aria-components" ;
import { Badge } from "@/components/ui/badge" ;
import {
GridList,
GridListItem,
GridListItemLabel,
} from "@/components/ui/grid-list" ;
type Task = { id : string ; title : string ; priority : "High" | "Medium" | "Low" };
const TASK_TYPE = "application/x-task" ;
const tone = { High: "danger" , Medium: "warning" , Low: "neutral" } as const ;
function Column ({ title , list } : { title : string ; list : ListData < Task > }) {
const { dragAndDropHooks } = useDragAndDrop ({
getItems : ( keys ) =>
[ ... keys]. map (( key ) => {
const task = list. getItem (key);
return {
[ TASK_TYPE ]: JSON . stringify (task),
"text/plain" : task?.title ?? "" ,
};
}),
acceptedDragTypes: [ TASK_TYPE ],
getDropOperation : () => "move" ,
async onInsert ( e ) {
const tasks = await readTasks (e.items);
if (e.target.dropPosition === "before" ) {
list. insertBefore (e.target.key, ... tasks);
} else if (e.target.dropPosition === "after" ) {
list. insertAfter (e.target.key, ... tasks);
}
},
async onRootDrop ( e ) {
list. append ( ... ( await readTasks (e.items)));
},
onReorder ( e ) {
if (e.target.dropPosition === "before" ) {
list. moveBefore (e.target.key, e.keys);
} else if (e.target.dropPosition === "after" ) {
list. moveAfter (e.target.key, e.keys);
}
},
onDragEnd ( e ) {
if (e.dropOperation === "move" && ! e.isInternal) list. remove ( ... e.keys);
},
renderDropIndicator : ( target ) => (
< DropIndicator
target = {target}
className = "-my-px h-0.5 rounded-full data-drop-target:bg-brand"
/>
),
});
return (
< div className = "flex min-w-0 flex-1 flex-col gap-2" >
< div className = "flex items-center justify-between px-1" >
< span className = "font-medium text-sm" >{title}</ span >
< span className = "text-muted-foreground text-xs tabular-nums" >
{list.items. length }
</ span >
</ div >
< GridList
aria-label = {title}
items = {list.items}
selectionMode = "multiple"
selectionBehavior = "replace"
dragAndDropHooks = {dragAndDropHooks}
renderEmptyState = {() => "Drop tasks here" }
className = "min-h-40 bg-muted/40 data-drop-target:ring-2 data-drop-target:ring-brand/40"
>
{( task ) => (
< GridListItem
textValue = {task.title}
className = "border bg-card shadow-xs data-selected:border-brand/40"
>
< GridListItemLabel className = "flex-1 whitespace-normal font-normal" >
{task.title}
</ GridListItemLabel >
< Badge size = "sm" variant = "dot" color = {tone[task.priority]}>
{task.priority}
</ Badge >
</ GridListItem >
)}
</ GridList >
</ div >
);
}
async function readTasks ( items : DropItem []) {
return Promise . all (
items
. filter (isTextDropItem)
. map ( async ( item ) => JSON . parse ( await item. getText ( TASK_TYPE )) as Task ),
);
}
export default function GridListRecipeKanban () {
const todo = useListData < Task >({
initialItems: [
{ id: "t1" , title: "Audit onboarding emails" , priority: "Medium" },
{ id: "t2" , title: "Fix CSV export timeout" , priority: "High" },
{ id: "t3" , title: "Refresh pricing screenshots" , priority: "Low" },
],
});
const doing = useListData < Task >({
initialItems: [
{ id: "t4" , title: "SSO for enterprise workspaces" , priority: "High" },
],
});
const done = useListData < Task >({
initialItems: [
{ id: "t5" , title: "Migrate to the new billing API" , priority: "Medium" },
],
});
return (
< div className = "flex w-full max-w-2xl flex-col gap-4 sm:flex-row" >
< Column title = "To do" list = {todo} />
< Column title = "In progress" list = {doing} />
< Column title = "Done" list = {done} />
</ div >
);
}
A separated list where onAction marks a notification as read. The unread dot has visually hidden text so it's announced.
Notifications (2) Mark all as read
Maya requested your review feat: usage-based billing · 5m Unread Leo mentioned you in #design “Can you check the empty states?” · 1h Unread New comment on Q3 roadmap Sam: Moved SSO to October · 3h Your pull request was approved fix: date picker focus · yesterday
import {
AtSignIcon,
GitPullRequestIcon,
MessageSquareIcon,
} from "lucide-react" ;
import { useListData } from "react-aria-components" ;
import { Button } from "@/components/ui/button" ;
import {
GridList,
GridListItem,
GridListItemDescription,
GridListItemLabel,
} from "@/components/ui/grid-list" ;
import { cn } from "@/lib/utils" ;
const icons = {
mention: AtSignIcon,
review: GitPullRequestIcon,
comment: MessageSquareIcon,
};
export default function GridListRecipeNotifications () {
const list = useListData ({
initialItems: [
{
id: "1" ,
kind: "review" as const ,
title: "Maya requested your review" ,
detail: "feat: usage-based billing · 5m" ,
unread: true ,
},
{
id: "2" ,
kind: "mention" as const ,
title: "Leo mentioned you in #design" ,
detail: "“Can you check the empty states?” · 1h" ,
unread: true ,
},
{
id: "3" ,
kind: "comment" as const ,
title: "New comment on Q3 roadmap" ,
detail: "Sam: Moved SSO to October · 3h" ,
unread: false ,
},
{
id: "4" ,
kind: "review" as const ,
title: "Your pull request was approved" ,
detail: "fix: date picker focus · yesterday" ,
unread: false ,
},
],
});
const unread = list.items. filter (( n ) => n.unread). length ;
const markRead = ( id : string ) => {
const item = list. getItem (id);
if (item) list. update (id, { ... item, unread: false });
};
return (
< div className = "flex w-full max-w-md flex-col overflow-hidden rounded-lg border bg-card shadow-xs" >
< div className = "flex items-center justify-between border-b px-4 py-2.5" >
< span className = "font-medium text-sm" >
Notifications{unread > 0 && ` (${ unread })` }
</ span >
< Button
size = "xs"
variant = "ghost"
isDisabled = {unread === 0 }
onPress = {() => {
for ( const n of list.items) markRead (n.id);
}}
>
Mark all as read
</ Button >
</ div >
< GridList
aria-label = "Notifications"
items = {list.items}
variant = "separated"
onAction = {( key ) => markRead ( String (key))}
className = "rounded-none border-0"
>
{( n ) => {
const Icon = icons[n.kind];
return (
< GridListItem textValue = {n.title}>
< Icon className = "size-4 shrink-0 text-muted-foreground" />
< span className = "flex min-w-0 flex-1 flex-col" >
< GridListItemLabel className = { cn ( ! n.unread && "font-normal" )}>
{n.title}
</ GridListItemLabel >
< GridListItemDescription >{n.detail}</ GridListItemDescription >
</ span >
{n.unread && (
< span className = "size-2 shrink-0 rounded-full bg-brand" >
< span className = "sr-only" >Unread</ span >
</ span >
)}
</ GridListItem >
);
}}
</ GridList >
</ div >
);
}
The list is role="grid" and each item is a role="row" with a single gridcell, so screen readers announce row position and selection.
Always provide aria-label or aria-labelledby.
Focus is a single tab stop with arrow key navigation between rows. Interactive children are reached with ← / → (or Tab with keyboardNavigationBehavior="tab").
Give icon-only buttons inside rows an aria-label, and make sure row text is available as textValue for typeahead and announcements.
Selection checkboxes are labelled from the row automatically.
Drag and drop has built-in keyboard and screen reader support, with live announcements for each step.
Key Action Tab Moves focus into and out of the list ↑ / ↓ Moves focus to the previous / next row (all four arrows in grid layout) ← / → Moves focus between the row and its interactive children Home / End Moves focus to the first / last row Page Up / Page Down Moves focus by a page Space Toggles selection of the focused row Enter Triggers onAction, follows a link, or starts a drag from the handle Shift +↑ / Shift +↓ Extends the selection (multiple) ⌘ /Ctrl +A Selects all (multiple) Esc Clears the selection, or cancels a drag Any character Typeahead (unless disallowTypeAhead)
On the GridList (use group-data-*/list: inside it):
Attribute Present when data-variantAlways: bordered, separated or plain data-emptyThere are no items data-focused / data-focus-visibleThe list itself has focus / keyboard focus data-drop-targetSomething is being dragged over the list data-layoutAlways: stack or grid data-orientationAlways: vertical or horizontal
On each GridListItem (use group-data-*/item: inside it):
Attribute Present when data-selectedThe row is selected data-focused / data-focus-visibleThe row has focus / keyboard focus data-focus-visible-withinA control inside the row has keyboard focus data-hovered / data-pressedHovered with a mouse / being pressed data-disabledThe row is disabled data-selection-modesingle or multipledata-allows-dragging / data-draggingDrag and drop is enabled / the row is being dragged data-drop-targetThe row is the current drop target
data-slotElement grid-listRoot grid-list-itemEach row grid-list-item-labelGridListItemLabel
The list has max-h-[inherit] and overflow-auto; give it a max-h-* or h-* to scroll.
Rows are flex items-center gap-3, so icons, text and trailing controls line up without extra wrappers. Give the text block flex-1 to push controls to the end.
className on GridList and GridListItem also accepts a function of the render props, e.g. ({ isSelected }) => ….
Also accepts every prop of React Aria's GridList .
Accepts every prop of <span>.
Accepts every prop of React Aria's Text. It sets slot="description".
List Box — selectable options without interactive content.
Table — rows and columns with sorting.
Menu — for per-row action menus.
Checkbox — the selection control used in rows.