Patterns
Resource Card
A card that opens one named thing, and the grid that holds the cards.
Patterns
A card that opens one named thing, and the grid that holds the cards.
import { Badge, ResourceCard, ResourceGrid, StatusLine, type LinkComponent } from 'ferry-ui'
import { BookOpen, Globe, LayoutDashboard, Smartphone } from 'lucide-react'
// In an app, the link component of your router opens the page. This one stays on the page.
const DemoLink: LinkComponent = ({ href, onClick, ...props }) => (
<a
href={href}
{...props}
onClick={(event) => {
onClick?.(event)
event.preventDefault()
}}
/>
)
const PROJECTS = [
{ id: 'web-app', name: 'Web app', icon: <LayoutDashboard />, owner: 'Maya Chen', plan: 'pro' },
{ id: 'mobile-app', name: 'Mobile app', icon: <Smartphone />, owner: 'Jonas Weber', plan: 'pro' },
{ id: 'website', name: 'Website', icon: <Globe />, owner: 'Priya Patel', plan: 'free' },
{ id: 'docs-portal', name: 'Docs portal', icon: <BookOpen />, owner: 'Maya Chen', plan: 'free' },
]
export default function ResourceCardHero() {
return (
<ResourceGrid aria-label="Projects">
{PROJECTS.map((project) => (
<ResourceCard
key={project.id}
name={project.name}
href={`#${project.id}`}
linkComponent={DemoLink}
icon={project.icon}
subtitle={`Owner: ${project.owner}`}
badges={
<Badge font="mono" shape="square">
{project.plan}
</Badge>
}
footer={<StatusLine tone="success">Project is active</StatusLine>}
/>
))}
</ResourceGrid>
)
}ResourceGrid. Give the grid an aria-label.Import the parts and put them together.
import { Badge, ResourceCard, ResourceCardSkeleton, ResourceGrid, StatusLine } from 'ferry-ui'
<ResourceGrid aria-label="">
<ResourceCard name="" href="" icon={<Icon />} subtitle="" badges={<Badge />} footer={<StatusLine />} />
<ResourceCardSkeleton />
</ResourceGrid>| Part | Element | Role |
|---|---|---|
ResourceGrid | ul | The grid. The width sets the number of columns. |
ResourceCard | li | One card. With href, the full card is a link. |
ResourceCardSkeleton | li | A placeholder with the size of a card. |
menu is the slot at the top right. Put a ghost button of size icon-tiny there, with a Dropdown Menu. A click on the menu does not open the card.
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
ResourceCard,
ResourceGrid,
toast,
type LinkComponent,
} from 'ferry-ui'
import { LayoutDashboard, MoreVertical, Smartphone } from 'lucide-react'
// In an app, the link component of your router opens the page. This one stays on the page.
const DemoLink: LinkComponent = ({ href, onClick, ...props }) => (
<a
href={href}
{...props}
onClick={(event) => {
onClick?.(event)
event.preventDefault()
}}
/>
)
const PROJECTS = [
{ id: 'web-app', name: 'Web app', icon: <LayoutDashboard />, owner: 'Maya Chen' },
{ id: 'mobile-app', name: 'Mobile app', icon: <Smartphone />, owner: 'Jonas Weber' },
]
export default function ResourceCardMenu() {
return (
<ResourceGrid aria-label="Projects">
{PROJECTS.map(({ id, name, icon, owner }) => (
<ResourceCard
key={id}
name={name}
href={`#${id}`}
linkComponent={DemoLink}
icon={icon}
subtitle={`Owner: ${owner}`}
menu={
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-tiny" icon={<MoreVertical />} aria-label={`Actions for ${name}`} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem onSelect={() => toast(`Renamed: ${name}`)}>Rename</DropdownMenuItem>
<DropdownMenuItem onSelect={() => toast(`Archived: ${name}`)}>Archive</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
}
/>
))}
</ResourceGrid>
)
}footer stays at the bottom of the card. Use it for a line of data and a StatusLine of Status.
The link covers the full card. Thus a button or a link in the footer needs className="relative z-10".
import { Button, ResourceCard, ResourceGrid, StatusLine, toast, type LinkComponent } from 'ferry-ui'
import { LayoutDashboard, Smartphone, Users } from 'lucide-react'
// In an app, the link component of your router opens the page. This one stays on the page.
const DemoLink: LinkComponent = ({ href, onClick, ...props }) => (
<a
href={href}
{...props}
onClick={(event) => {
onClick?.(event)
event.preventDefault()
}}
/>
)
export default function ResourceCardFooter() {
return (
<ResourceGrid aria-label="Projects">
<ResourceCard
name="Web app"
href="#web-app"
linkComponent={DemoLink}
icon={<LayoutDashboard />}
footer={
<>
<span className="inline-flex items-center gap-1.5 text-[13px] text-foreground-lighter">
<Users className="size-3.5" aria-hidden="true" />
12 members
</span>
<StatusLine tone="success">Project is active</StatusLine>
</>
}
/>
<ResourceCard
name="Mobile app"
href="#mobile-app"
linkComponent={DemoLink}
icon={<Smartphone />}
footer={
<div className="flex items-center justify-between gap-3">
<StatusLine tone="destructive">Import failed</StatusLine>
{/* `relative z-10` puts the button above the link of the card. */}
<Button size="tiny" className="relative z-10" onClick={() => toast('The import starts again')}>
Retry
</Button>
</div>
}
/>
</ResourceGrid>
)
}A card with no href is not a link. It does not change on hover.
import { Badge, ResourceCard, ResourceGrid, StatusLine } from 'ferry-ui'
import { Archive, FolderKanban } from 'lucide-react'
export default function ResourceCardStatic() {
return (
<ResourceGrid aria-label="Archived projects">
<ResourceCard
name="Billing portal"
icon={<FolderKanban />}
subtitle="Owner: Maya Chen"
badges={
<Badge font="mono" shape="square">
pro
</Badge>
}
footer={<StatusLine tone="neutral">Archived on Mar 4, 2026</StatusLine>}
/>
<ResourceCard name="Internal wiki" icon={<Archive />} subtitle="Owner: Jonas Weber" />
</ResourceGrid>
)
}While the list loads, show ResourceCardSkeleton in the grid. Set aria-busy on the grid.
import * as React from 'react'
import { Label, ResourceCard, ResourceCardSkeleton, ResourceGrid, StatusLine, Switch } from 'ferry-ui'
import { LayoutDashboard, Smartphone } from 'lucide-react'
export default function ResourceCardLoading() {
// In an app, `loading` comes from the request that loads the list.
const [loading, setLoading] = React.useState(true)
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<Switch id="projects-loading" checked={loading} onCheckedChange={setLoading} />
<Label htmlFor="projects-loading">Loading</Label>
</div>
<ResourceGrid aria-label="Projects" aria-busy={loading}>
{loading ? (
<>
<ResourceCardSkeleton />
<ResourceCardSkeleton />
</>
) : (
<>
<ResourceCard
name="Web app"
icon={<LayoutDashboard />}
subtitle="Owner: Maya Chen"
footer={<StatusLine tone="success">Project is active</StatusLine>}
/>
<ResourceCard
name="Mobile app"
icon={<Smartphone />}
subtitle="Owner: Jonas Weber"
footer={<StatusLine tone="success">Project is active</StatusLine>}
/>
</>
)}
</ResourceGrid>
</div>
)
}minItemWidth sets the minimum width of a column. The default is 248px. Pass a number of pixels or a CSS length.
import { ResourceCard, ResourceGrid, StatusLine } from 'ferry-ui'
import { Calendar, CreditCard, HardDrive, Mail, MessageSquare, Webhook } from 'lucide-react'
const INTEGRATIONS = [
{ name: 'Email', icon: <Mail />, connected: true },
{ name: 'Calendar', icon: <Calendar />, connected: true },
{ name: 'Webhooks', icon: <Webhook />, connected: true },
{ name: 'Payments', icon: <CreditCard />, connected: false },
{ name: 'Storage', icon: <HardDrive />, connected: true },
{ name: 'Chat', icon: <MessageSquare />, connected: false },
]
export default function ResourceCardMinWidth() {
return (
<ResourceGrid aria-label="Integrations" minItemWidth={160}>
{INTEGRATIONS.map((integration) => (
<ResourceCard
key={integration.name}
name={integration.name}
icon={integration.icon}
footer={
<StatusLine tone={integration.connected ? 'success' : 'neutral'}>
{integration.connected ? 'Connected' : 'Not connected'}
</StatusLine>
}
/>
))}
</ResourceGrid>
)
}Outside a grid, set as="div". The card is then not a list item.
import { Badge, ResourceCard, StatusLine, type LinkComponent } from 'ferry-ui'
import { LayoutDashboard } from 'lucide-react'
// In an app, the link component of your router opens the page. This one stays on the page.
const DemoLink: LinkComponent = ({ href, onClick, ...props }) => (
<a
href={href}
{...props}
onClick={(event) => {
onClick?.(event)
event.preventDefault()
}}
/>
)
export default function ResourceCardStandalone() {
return (
<ResourceCard
as="div"
className="w-full max-w-xs"
name="Web app"
href="#web-app"
linkComponent={DemoLink}
icon={<LayoutDashboard />}
subtitle="Owner: Maya Chen"
badges={
<Badge font="mono" shape="square">
pro
</Badge>
}
footer={<StatusLine tone="success">Project is active</StatusLine>}
/>
)
}The name is an <h3> by default. titleAs changes the element. Use h2 if the grid comes directly below the title of the page.
<ResourceCard titleAs="h2" name="Web app" href="/projects/web-app" />aria-label with the name of the card.aria-busy.ResourceGrid also accepts each attribute of the <ul> element. The two cards accept the attributes of their element.
resourceGridClassName holds the classes of the grid. Use it for a list element that you do not render with ResourceGrid.
| Prop | Type | Default |
|---|---|---|
minItemWidth | string | number | - |
Minimum column width before the grid wraps to fewer columns: a number of px ( | ||
The card renders its link with the link component of Link Provider.
| Prop | Type | Default |
|---|---|---|
nameRequired | ReactNode | - |
Name of the entity, 15px medium, truncated on one line. It is also the accessible name of the card link. | ||
href | string | - |
Where the card leads. The whole card becomes clickable through a stretched link rendered with the | ||
linkComponent | LinkComponent | - |
Router-aware link used for | ||
icon | ReactNode | - |
16px line icon before the name (entity type or kind). Decorative. | ||
menu | ReactNode | - |
Top-right slot, usually a ghost | ||
subtitle | ReactNode | - |
Line under the name (source, owner, description…), 13px lighter text. Truncate long values yourself. | ||
badges | ReactNode | - |
Row of small tags, usually mono square | ||
footer | ReactNode | - |
Bottom block pinned to the bottom of the card: an address/metadata line and a status row ( | ||
as | "div" | "li" | li |
Element rendered: | ||
titleAs | "div" | "h2" | "h3" | "h4" | "h5" | "h6" | h3 |
Element wrapping the name. Defaults to | ||
| Prop | Type | Default |
|---|---|---|
as | "div" | "li" | li |
Element rendered: | ||