Image Grid
An async-loaded image gallery with selectable items built with the ListBox component, and styled with Tailwind CSS.
Example#
import {Item, ListBox, ProgressBar, Text} from 'react-aria-components';
import {useAsyncList} from 'react-stately';
import CheckCircleIcon from '@spectrum-icons/workflow/CheckmarkCircle';
type Item = {
user: { name: string };
urls: { regular: string };
alt_description: string;
};
function ImageGridExample() {
let list = useAsyncList<Item, number>({
async load({ signal, cursor }) {
let page = cursor || 1;
let res = await fetch(
`https://api.unsplash.com/photos?page= &per_page=25&client_id=AJuU-FPh11hn7RuumUllp4ppT8kgiLS7LtOHp_sp4nc`,
{ signal }
);
let items = await res.json();
return { items, cursor: page + 1 };
}
});
let renderEmptyState = () => {
if (list.isLoading) {
return <ProgressCircle />;
}
};
return (
<div className="bg-gradient-to-r from-sky-500 to-teal-500 p-2 sm:p-8 rounded-lg flex justify-center">
<ListBox
aria-label="Images"
items={list.items}
selectionMode="multiple"
layout="grid"
renderEmptyState={renderEmptyState}
className="overflow-auto outline-none bg-white rounded-lg shadow p-2 h-[350px] w-full max-w-[372px] grid grid-cols-3 gap-3 empty:flex"
>
{(item) => (
<Item
textValue={item.user.name}
className="relative rounded outline-none group cursor-default"
>
<img
src={item.urls.regular}
alt={item.alt_description}
className="h-[80px] w-full object-cover rounded group-selected:ring-2 group-focus-visible:ring-4 group-selected:group-focus-visible:ring-4 ring-offset-2 ring-sky-600"
/>
<Text
slot="label"
className="text-[11px] text-gray-700 font-semibold overflow-hidden text-ellipsis whitespace-nowrap max-w-full block mt-1"
>
{item.user.name}
</Text>
<div className="absolute top-2 left-2 text-sky-800 rounded-full leading-[0] bg-white border border-white border-solid hidden group-selected:block">
<CheckCircleIcon size="S" />
</div>
</Item>
)}
</ListBox>
</div>
);
}
function ProgressCircle() {
return (
<ProgressBar
aria-label="Loading…"
isIndeterminate
className="flex items-center justify-center w-full"
>
<svg
className="animate-spin h-5 w-5 text-sky-800"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25 stroke-current stroke-[4px]"
cx="12"
cy="12"
r="10"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
>
</path>
</svg>
</ProgressBar>
);
}
import {
Item,
ListBox,
ProgressBar,
Text
} from 'react-aria-components';
import {useAsyncList} from 'react-stately';
import CheckCircleIcon from '@spectrum-icons/workflow/CheckmarkCircle';
type Item = {
user: { name: string };
urls: { regular: string };
alt_description: string;
};
function ImageGridExample() {
let list = useAsyncList<Item, number>({
async load({ signal, cursor }) {
let page = cursor || 1;
let res = await fetch(
`https://api.unsplash.com/photos?page= &per_page=25&client_id=AJuU-FPh11hn7RuumUllp4ppT8kgiLS7LtOHp_sp4nc`,
{ signal }
);
let items = await res.json();
return { items, cursor: page + 1 };
}
});
let renderEmptyState = () => {
if (list.isLoading) {
return <ProgressCircle />;
}
};
return (
<div className="bg-gradient-to-r from-sky-500 to-teal-500 p-2 sm:p-8 rounded-lg flex justify-center">
<ListBox
aria-label="Images"
items={list.items}
selectionMode="multiple"
layout="grid"
renderEmptyState={renderEmptyState}
className="overflow-auto outline-none bg-white rounded-lg shadow p-2 h-[350px] w-full max-w-[372px] grid grid-cols-3 gap-3 empty:flex"
>
{(item) => (
<Item
textValue={item.user.name}
className="relative rounded outline-none group cursor-default"
>
<img
src={item.urls.regular}
alt={item.alt_description}
className="h-[80px] w-full object-cover rounded group-selected:ring-2 group-focus-visible:ring-4 group-selected:group-focus-visible:ring-4 ring-offset-2 ring-sky-600"
/>
<Text
slot="label"
className="text-[11px] text-gray-700 font-semibold overflow-hidden text-ellipsis whitespace-nowrap max-w-full block mt-1"
>
{item.user.name}
</Text>
<div className="absolute top-2 left-2 text-sky-800 rounded-full leading-[0] bg-white border border-white border-solid hidden group-selected:block">
<CheckCircleIcon size="S" />
</div>
</Item>
)}
</ListBox>
</div>
);
}
function ProgressCircle() {
return (
<ProgressBar
aria-label="Loading…"
isIndeterminate
className="flex items-center justify-center w-full"
>
<svg
className="animate-spin h-5 w-5 text-sky-800"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25 stroke-current stroke-[4px]"
cx="12"
cy="12"
r="10"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
>
</path>
</svg>
</ProgressBar>
);
}
import {
Item,
ListBox,
ProgressBar,
Text
} from 'react-aria-components';
import {useAsyncList} from 'react-stately';
import CheckCircleIcon from '@spectrum-icons/workflow/CheckmarkCircle';
type Item = {
user: { name: string };
urls: {
regular: string;
};
alt_description:
string;
};
function ImageGridExample() {
let list =
useAsyncList<
Item,
number
>({
async load(
{
signal,
cursor
}
) {
let page =
cursor || 1;
let res =
await fetch(
`https://api.unsplash.com/photos?page= &per_page=25&client_id=AJuU-FPh11hn7RuumUllp4ppT8kgiLS7LtOHp_sp4nc`,
{ signal }
);
let items =
await res
.json();
return {
items,
cursor: page +
1
};
}
});
let renderEmptyState =
() => {
if (
list.isLoading
) {
return (
<ProgressCircle />
);
}
};
return (
<div className="bg-gradient-to-r from-sky-500 to-teal-500 p-2 sm:p-8 rounded-lg flex justify-center">
<ListBox
aria-label="Images"
items={list
.items}
selectionMode="multiple"
layout="grid"
renderEmptyState={renderEmptyState}
className="overflow-auto outline-none bg-white rounded-lg shadow p-2 h-[350px] w-full max-w-[372px] grid grid-cols-3 gap-3 empty:flex"
>
{(item) => (
<Item
textValue={item
.user.name}
className="relative rounded outline-none group cursor-default"
>
<img
src={item
.urls
.regular}
alt={item
.alt_description}
className="h-[80px] w-full object-cover rounded group-selected:ring-2 group-focus-visible:ring-4 group-selected:group-focus-visible:ring-4 ring-offset-2 ring-sky-600"
/>
<Text
slot="label"
className="text-[11px] text-gray-700 font-semibold overflow-hidden text-ellipsis whitespace-nowrap max-w-full block mt-1"
>
{item.user
.name}
</Text>
<div className="absolute top-2 left-2 text-sky-800 rounded-full leading-[0] bg-white border border-white border-solid hidden group-selected:block">
<CheckCircleIcon size="S" />
</div>
</Item>
)}
</ListBox>
</div>
);
}
function ProgressCircle() {
return (
<ProgressBar
aria-label="Loading…"
isIndeterminate
className="flex items-center justify-center w-full"
>
<svg
className="animate-spin h-5 w-5 text-sky-800"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25 stroke-current stroke-[4px]"
cx="12"
cy="12"
r="10"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
>
</path>
</svg>
</ProgressBar>
);
}
Tailwind config#
This example uses the tailwindcss-react-aria-components plugin. Add it to your tailwind.config.js
:
module.exports = {
// ...
plugins: [
require('tailwindcss-react-aria-components')
]
};
module.exports = {
// ...
plugins: [
require('tailwindcss-react-aria-components')
]
};
module.exports = {
// ...
plugins: [
require(
'tailwindcss-react-aria-components'
)
]
};
Components#
ListBox
A listbox displays a list of options, and allows a user to select one or more of them.
ProgressBar
A progress bar shows progress of an operation over time.