# Resource Card

A card that opens one named thing, and the grid that holds the cards.

```tsx
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>
  )
}
```

## Usage guidelines

- **A collection that people browse.** Use the cards for named things, such as projects, workspaces or integrations.
- **Records to compare are a table.** For dense rows, use [Table](/docs/components/table).
- **Not a container.** For other content, use [Card](/docs/components/card). For a KPI, use [Metric Card](/docs/components/metric-card).
- **Cards go in a grid.** Put the cards in a `ResourceGrid`. Give the grid an `aria-label`.

## Anatomy

Import the parts and put them together.

```tsx title="Anatomy"

<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. |

## Examples

### Menu

`menu` is the slot at the top right. Put a `ghost` button of size `icon-tiny` there, with a [Dropdown Menu](/docs/components/dropdown-menu). A click on the menu does not open the card.

```tsx
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

`footer` stays at the bottom of the card. Use it for a line of data and a `StatusLine` of [Status](/docs/components/status).

The link covers the full card. Thus a button or a link in the footer needs `className="relative z-10"`.

```tsx
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>
  )
}
```

### Static card

A card with no `href` is not a link. It does not change on hover.

```tsx
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>
  )
}
```

### Loading

While the list loads, show `ResourceCardSkeleton` in the grid. Set `aria-busy` on the grid.

```tsx
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>
  )
}
```

### Column width

`minItemWidth` sets the minimum width of a column. The default is 248px. Pass a number of pixels or a CSS length.

```tsx
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>
  )
}
```

### Standalone card

Outside a grid, set `as="div"`. The card is then not a list item.

```tsx
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>}
    />
  )
}
```

### Heading level

The name is an `<h3>` by default. `titleAs` changes the element. Use `h2` if the grid comes directly below the title of the page.

```tsx
<ResourceCard titleAs="h2" name="Web app" href="/projects/web-app" />
```

## Accessibility

- The name is the accessible name of the link. The icon is decorative.
- The link gets the keyboard focus first, then the menu. The focus ring shows around the full card.
- Give the trigger of the menu an `aria-label` with the name of the card.
- Screen readers do not see the skeletons. That is why the grid needs `aria-busy`.

## API reference

`ResourceGrid` also accepts each attribute of the `<ul>` element. The two cards accept the attributes of their element.

### ResourceGrid

`resourceGridClassName` holds the classes of the grid. Use it for a list element that you do not render with `ResourceGrid`.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `minItemWidth` | `string \| number` |  | Minimum column width before the grid wraps to fewer columns: a number of px (`200`) or any CSS length (`'16rem'`). Defaults to 248px. |

### ResourceCard

The card renders its link with the link component of [Link Provider](/docs/utilities/link-provider).

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `name` (required) | `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` (router adapter). Omit it for a static, non-clickable card. |
| `linkComponent` | `LinkComponent` |  | Router-aware link used for `href`. Defaults to the nearest `LinkProvider` component, else a plain `<a>`. |
| `icon` | `ReactNode` |  | 16px line icon before the name (entity type or kind). Decorative. |
| `menu` | `ReactNode` |  | Top-right slot, usually a ghost `icon-tiny` Button opening a DropdownMenu (⋮). It sits above the stretched link, so clicking it never opens the card. Give its trigger an `aria-label`. |
| `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 `Badge`s (plan, region, version, counts). Wraps. |
| `footer` | `ReactNode` |  | Bottom block pinned to the bottom of the card: an address/metadata line and a status row (`StatusLine` + a timestamp). Any interactive element in it needs `relative z-10` to sit above the stretched link. |
| `as` | `"div" \| "li"` | `li` | Element rendered: `li` (default, inside a `ResourceGrid`) or `div` for a standalone card. |
| `titleAs` | `"div" \| "h2" \| "h3" \| "h4" \| "h5" \| "h6"` | `h3` | Element wrapping the name. Defaults to `h3`; pick the level that fits the page outline (`h2` when the grid sits right under the page `h1`), or `div` when the cards must not be headings. |

### ResourceCardSkeleton

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `as` | `"div" \| "li"` | `li` | Element rendered: `li` (default, inside a `ResourceGrid`) or `div`. Match the real cards. |
