Skip to content
Facade UI
Colour mode
GitHub (opens in a new tab)
blocksection

Card list

A grid of linked cards for posts, changelog entries or case studies, with machine-readable dates.

Also ships a motion variant: Card list (motion). The static one is the default.

Preview

Open full width (opens in a new tab)
Preview width

Installation

Package manager
pnpm dlx shadcn@latest add https://facadeui.dev/r/card-list.json

Pulls in badge, container, heading, section, section-header, types, utils. The CLI installs them for you.

Usage

The exact source of the preview above.

demos/card-list.tsx
import { CardList } from "@registry/sections/card-list"

import { POSTS } from "./content"

export function Demo() {
  return (
    <CardList
      eyebrow="Blog"
      title="Latest writing"
      description="Cards use a stretched link, so the accessible name is the title rather than the whole card."
      items={POSTS}
    />
  )
}

Source

What the CLI copies into your project, byte for byte.

components/sections/card-list.tsx
/**
 * CardList — a grid of linked cards: blog posts, changelog entries, case studies.
 *
 * The generic "list of things with an image, a title and a date" section that
 * every marketing site needs and that does not warrant its own component per
 * content type.
 *
 * a11y: same stretched-link pattern as `FeatureGrid` — the anchor sits on the
 * title and its hit area covers the card, so the accessible name is the title
 * rather than the title plus the excerpt plus the date. Dates are `<time>` with
 * a machine-readable `dateTime`, and the human label is whatever you pass, so a
 * relative label like "3 days ago" stays unambiguous.
 *
 * Cover images are `alt=""` by default: on a card whose title is right there,
 * the image is decoration. Pass `imageAlt` when it genuinely carries meaning.
 *
 * Dependencies: react, @/lib/types, @/lib/utils,
 * @/components/ui/badge, @/components/ui/container, @/components/ui/heading,
 * @/components/ui/section, @/components/ui/section-header.
 */

import type { ElementType } from "react"

import type {
  HeadingLevel,
  ImageComponent,
  LinkComponent,
  ListSlotProps,
  SectionBaseProps,
} from "@/lib/types"
import { cn, slugId } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Container } from "@/components/ui/container"
import { Heading } from "@/components/ui/heading"
import { Section } from "@/components/ui/section"
import { SectionHeader } from "@/components/ui/section-header"

export interface CardItem {
  title: string
  href: string
  description?: string
  imageSrc?: string
  /** Only set this when the image carries meaning the title does not. */
  imageAlt?: string
  /** ISO 8601, for the `datetime` attribute. */
  dateTime?: string
  /** Human label for the date, e.g. "12 March 2026" or "3 days ago". */
  dateLabel?: string
  tag?: string
  external?: boolean
  id?: string
}

export interface CardListProps extends SectionBaseProps, ListSlotProps {
  items: CardItem[]
  title?: string
  eyebrow?: string
  description?: string
  image?: ImageComponent
  link?: LinkComponent
  columns?: 2 | 3
  /** `card` gives each entry a surface; `plain` is a bare list. */
  variant?: "card" | "plain"
  itemHeadingLevel?: HeadingLevel
  align?: "start" | "center"
}

export function CardList({
  items,
  title,
  eyebrow,
  description,
  image,
  link,
  columns = 3,
  variant = "card",
  itemHeadingLevel,
  align = "start",
  listAs,
  itemAs,
  headingLevel = 2,
  as,
  spacing = "md",
  className,
  id,
}: CardListProps) {
  const List = (listAs ?? "ul") as ElementType
  const Item = (itemAs ?? "li") as ElementType
  const Image = (image ?? "img") as ElementType
  const Link = (link ?? "a") as ElementType
  const titleId = title ? (id ? `${id}-title` : slugId(title)) : undefined
  const itemLevel = itemHeadingLevel ?? (Math.min(headingLevel + 1, 6) as HeadingLevel)

  return (
    <Section
      as={as ?? (title ? "section" : "div")}
      spacing={spacing}
      labelledBy={titleId}
      id={id}
      className={className}
    >
      <Container className="flex flex-col gap-14">
        {title ? (
          <SectionHeader
            align={align}
            eyebrow={eyebrow}
            title={title}
            description={description}
            headingLevel={headingLevel}
            titleId={titleId}
          />
        ) : null}

        <List
          className={cn(
            "grid gap-6",
            columns === 2 ? "sm:grid-cols-2" : "sm:grid-cols-2 lg:grid-cols-3",
          )}
        >
          {items.map((item) => (
            <Item
              key={item.id ?? item.href}
              className={cn(
                "group relative flex flex-col gap-4 overflow-hidden",
                variant === "card" && "bg-card text-card-foreground rounded-xl border",
                "duration-facade-fast ease-facade-out transition-colors",
                variant === "card" && "hover:border-ring",
                "has-[a:focus-visible]:ring-ring has-[a:focus-visible]:ring-offset-background has-[a:focus-visible]:rounded-xl has-[a:focus-visible]:ring-2 has-[a:focus-visible]:ring-offset-2",
              )}
            >
              {item.imageSrc ? (
                <Image
                  src={item.imageSrc}
                  alt={item.imageAlt ?? ""}
                  loading="lazy"
                  decoding="async"
                  className={cn(
                    "bg-muted aspect-[16/9] w-full object-cover",
                    variant === "plain" && "rounded-xl",
                  )}
                />
              ) : null}

              <div
                className={cn(
                  "flex flex-1 flex-col gap-3",
                  variant === "card" && "p-6 pt-2",
                )}
              >
                {item.tag || item.dateLabel ? (
                  <p className="text-muted-foreground flex flex-wrap items-center gap-3 text-sm">
                    {item.tag ? (
                      <Badge variant="muted" size="sm">
                        {item.tag}
                      </Badge>
                    ) : null}
                    {item.dateLabel ? (
                      <time dateTime={item.dateTime}>{item.dateLabel}</time>
                    ) : null}
                  </p>
                ) : null}

                <Heading
                  level={itemLevel}
                  className="text-foreground text-balance font-semibold"
                >
                  <Link
                    href={item.href}
                    {...(item.external
                      ? { target: "_blank", rel: "noopener noreferrer" }
                      : {})}
                    className="outline-none after:absolute after:inset-0 after:content-['']"
                  >
                    {item.title}
                    {item.external ? (
                      <span className="sr-only"> (opens in a new tab)</span>
                    ) : null}
                  </Link>
                </Heading>

                {item.description ? (
                  <p className="text-muted-foreground text-pretty text-sm">
                    {item.description}
                  </p>
                ) : null}
              </div>
            </Item>
          ))}
        </List>
      </Container>
    </Section>
  )
}

Props

CardListProps

Extends SectionBaseProps, ListSlotProps.

Props for CardListProps
PropTypeDefault
items*RequiredCardItem[]
titlestring
eyebrowstring
descriptionstring
imageImageComponent
linkLinkComponent
columns2 | 33
variant`card` gives each entry a surface; `plain` is a bare list."card" | "plain""card"
itemHeadingLevelHeadingLevel
align"start" | "center""start"