Layout

Card

A bordered container for one item, with header, body, footer and media slots, optionally one big link.

Built on
:has()Stretched link pseudo-element
Client JavaScript
None
Works without React
Yes, as HTML and CSS
Source, gzipped
2.1 kB
Dependencies
React

Billing service

Deployed 2 h ago to eu-west-1

Try its options

Loading its options.

Code
import { Button } from "@/components/equip/button";
import { Card, CardFooter, CardHeader } from "@/components/equip/card";

export default function Demo() {
  return (
    <Card as="article" padding="s" style={{ width: 208 }}>
      <CardHeader title="Billing service" description="Deployed 2 h ago to eu-west-1" />
      <CardFooter>
        <span>3 open issues</span>
        <Button size="s" variant="outline">
          Open
        </Button>
      </CardFooter>
    </Card>
  );
}
Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<article style="width:208px" class="eq-card" data-padding="s" data-tone="surface">
  <div class="eq-card-header">
    <div class="eq-card-heading">
      <h3 class="eq-card-title">Billing service</h3>
      <p class="eq-card-description">Deployed 2 h ago to eu-west-1</p>
    </div>
  </div>
  <div class="eq-card-footer">
    <span>3 open issues</span>
    <button type="button" class="eq-button" data-variant="outline" data-size="s">
      <span class="eq-button-label">Open</span>
    </button>
  </div>
</article>

Examples

Whole card as a link

The title is the only link; its pseudo-element covers the card, so the Star button still takes its own clicks.

Code
import { Button } from "@/components/equip/button";
import { Card, CardFooter, CardHeader } from "@/components/equip/card";

const projects = [
  { name: "Checkout", meta: "Next.js, 14 contributors", href: "#checkout", stars: 128 },
  { name: "Ledger API", meta: "Go, 6 contributors", href: "#ledger-api", stars: 64 },
];

export default function Example() {
  return (
    <ul
      style={{
        display: "grid",
        gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))",
        gap: 12,
        margin: 0,
        padding: 0,
      }}
    >
      {projects.map((project) => (
        <Card as="li" key={project.name} interactive>
          <CardHeader title={project.name} description={project.meta} href={project.href} />
          <CardFooter>
            <span>{project.stars} stars</span>
            <Button size="s" variant="ghost">
              Star
            </Button>
          </CardFooter>
        </Card>
      ))}
    </ul>
  );
}
HTMLworks in any framework, with the part's CSS
<ul style="display:grid;grid-template-columns:repeat(auto-fill, minmax(200px, 1fr));gap:12px;margin:0;padding:0">
  <li class="eq-card" data-padding="m" data-tone="surface" data-interactive="true">
    <div class="eq-card-header">
      <div class="eq-card-heading">
        <h3 class="eq-card-title">
          <a class="eq-card-link" href="#checkout">Checkout</a>
        </h3>
        <p class="eq-card-description">Next.js, 14 contributors</p>
      </div>
    </div>
    <div class="eq-card-footer">
      <span>128 stars</span>
      <button type="button" class="eq-button" data-variant="ghost" data-size="s">
        <span class="eq-button-label">Star</span>
      </button>
    </div>
  </li>
  <li class="eq-card" data-padding="m" data-tone="surface" data-interactive="true">
    <div class="eq-card-header">
      <div class="eq-card-heading">
        <h3 class="eq-card-title">
          <a class="eq-card-link" href="#ledger-api">Ledger API</a>
        </h3>
        <p class="eq-card-description">Go, 6 contributors</p>
      </div>
    </div>
    <div class="eq-card-footer">
      <span>64 stars</span>
      <button type="button" class="eq-button" data-variant="ghost" data-size="s">
        <span class="eq-button-label">Star</span>
      </button>
    </div>
  </li>
</ul>

With media

CardMedia bleeds over the padding and clips to the card's corners one pixel inside the border.

Quarterly usage report

Generated 1 Oct for Larder

API calls rose 18 percent; storage stayed flat at 412 GB.

Code
import { Button } from "@/components/equip/button";
import { Card, CardBody, CardFooter, CardHeader, CardMedia } from "@/components/equip/card";

export default function Example() {
  return (
    <Card as="article" style={{ maxWidth: 300 }}>
      <CardMedia>
        <div
          aria-hidden="true"
          style={{
            height: 96,
            background:
              "linear-gradient(135deg, var(--eq-tint-strong), var(--eq-tint) 60%, var(--eq-surface))",
          }}
        />
      </CardMedia>
      <CardHeader title="Quarterly usage report" description="Generated 1 Oct for Larder" />
      <CardBody>
        <p>API calls rose 18 percent; storage stayed flat at 412 GB.</p>
      </CardBody>
      <CardFooter>
        <span>PDF, 2.4 MB</span>
        <Button size="s" variant="outline">
          Download
        </Button>
      </CardFooter>
    </Card>
  );
}
HTMLworks in any framework, with the part's CSS
<article style="max-width:300px" class="eq-card" data-padding="m" data-tone="surface">
  <div class="eq-card-media">
    <div aria-hidden="true" style="height:96px;background:linear-gradient(135deg, var(--eq-tint-strong), var(--eq-tint) 60%, var(--eq-surface))"></div>
  </div>
  <div class="eq-card-header">
    <div class="eq-card-heading">
      <h3 class="eq-card-title">Quarterly usage report</h3>
      <p class="eq-card-description">Generated 1 Oct for Larder</p>
    </div>
  </div>
  <div class="eq-card-body">
    <p>API calls rose 18 percent; storage stayed flat at 412 GB.</p>
  </div>
  <div class="eq-card-footer">
    <span>PDF, 2.4 MB</span>
    <button type="button" class="eq-button" data-variant="outline" data-size="s">
      <span class="eq-button-label">Download</span>
    </button>
  </div>
</article>

Tones

Only raised carries a shadow; save it for a card that floats above the page, like a dragged item.

Surface

The default, on the page background.

Outline

Lighter, for dense grids.

Raised

For a card that floats.

Dragging a task lifts it here.
Code
import { Button } from "@/components/equip/button";
import { Card, CardBody, CardHeader } from "@/components/equip/card";

export default function Example() {
  return (
    <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", gap: 12 }}>
      <Card padding="s">
        <CardHeader title="Surface" description="The default, on the page background." />
      </Card>
      <Card padding="s" tone="outline">
        <CardHeader title="Outline" description="Lighter, for dense grids." />
      </Card>
      <Card padding="s" tone="raised">
        <CardHeader
          title="Raised"
          description="For a card that floats."
          actions={
            <Button size="s" variant="ghost" aria-label="Dismiss">
              <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
                <path d="M4 4l8 8M12 4l-8 8" />
              </svg>
            </Button>
          }
        />
        <CardBody>Dragging a task lifts it here.</CardBody>
      </Card>
    </div>
  );
}
HTMLworks in any framework, with the part's CSS
<div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(180px, 1fr));gap:12px">
  <div class="eq-card" data-padding="s" data-tone="surface">
    <div class="eq-card-header">
      <div class="eq-card-heading">
        <h3 class="eq-card-title">Surface</h3>
        <p class="eq-card-description">The default, on the page background.</p>
      </div>
    </div>
  </div>
  <div class="eq-card" data-padding="s" data-tone="outline">
    <div class="eq-card-header">
      <div class="eq-card-heading">
        <h3 class="eq-card-title">Outline</h3>
        <p class="eq-card-description">Lighter, for dense grids.</p>
      </div>
    </div>
  </div>
  <div class="eq-card" data-padding="s" data-tone="raised">
    <div class="eq-card-header">
      <div class="eq-card-heading">
        <h3 class="eq-card-title">Raised</h3>
        <p class="eq-card-description">For a card that floats.</p>
      </div>
      <div class="eq-card-actions">
        <button aria-label="Dismiss" type="button" class="eq-button" data-variant="ghost" data-size="s">
          <span class="eq-button-label"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8" /></svg></span>
        </button>
      </div>
    </div>
    <div class="eq-card-body">Dragging a task lifts it here.</div>
  </div>
</div>

Props

PropTypeDefaultWhat it does
as"div" | "article" | "section" | "li""div"The root element. article for a self-contained item, li inside a list.
interactivebooleanfalseStretches the CardHeader link over the whole card, lifts it on hover and presses it on click.
padding"none" | "s" | "m" | "l""m"Inner spacing; also the gap between the card's pieces.
tone"surface" | "raised" | "outline""surface"surface sits on the page, outline is lighter, raised adds the shadow for a card that floats.
...restComponentProps<"div">noneEvery native attribute of the root element.
CardHeadertitle, description?, actions?, href?noneAn h3 title, an optional line of meta below it and an actions slot at the end. href turns the title into the card's link.
CardBodyComponentProps<"div">noneThe main content, in secondary ink.
CardFooterComponentProps<"div">noneA row under a hairline, pushed to the bottom when cards in a grid share a height.
CardMediaComponentProps<"div">noneA top slot that bleeds to the edges and clips to the card's corners. Takes an image, video or any element.

Anatomy

root.eq-cardThe element chosen by as.
media.eq-card-mediaNegative margins cancel the padding; corners are the card radius less the border.
header.eq-card-header
heading.eq-card-headingWraps the title and description so actions stay at the end.
title.eq-card-titleAn h3.
link.eq-card-linkRendered when CardHeader has href. On an interactive card its ::after covers the card.
description.eq-card-description
actions.eq-card-actions
body.eq-card-body
footer.eq-card-footer

Keyboard

TabOn an interactive card, focuses the title link, then any controls inside. The ring is drawn around the whole card.
EnterFollows the card's link.

Notes

  • An interactive card is one real link, the title, so screen readers announce it by the title and not by every word on the card.
  • Buttons and links inside an interactive card sit above the stretched link, so they keep their own clicks and focus.
  • Do not wrap a card in a link or put a link around its content: nested interactive elements are invalid and confuse assistive tech.
  • The title is an h3, which suits a card in a section headed by an h2; there is no prop to change the level.
  • Renders on the server with no client JavaScript.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/card.tsx82 lines
import type { ComponentProps, ReactNode } from "react";
import "./card.css";

export type CardProps = ComponentProps<"div"> & {
  /** The element at the root. Use article for a self-contained item, li inside a list. */
  as?: "div" | "article" | "section" | "li";
  /** Makes the whole card one link target. Pair it with CardHeader href. */
  interactive?: boolean;
  padding?: "none" | "s" | "m" | "l";
  /** raised adds the shadow; use it only for a card that floats above the page. */
  tone?: "surface" | "raised" | "outline";
};

export function Card({
  as = "div",
  interactive = false,
  padding = "m",
  tone = "surface",
  className,
  ...rest
}: CardProps) {
  // The root changes element but never its attributes, so the div typing holds for all four.
  const Root = as as "div";
  return (
    <Root
      {...rest}
      className={["eq-card", className].filter(Boolean).join(" ")}
      data-padding={padding}
      data-tone={tone}
      data-interactive={interactive || undefined}
    />
  );
}

export type CardHeaderProps = Omit<ComponentProps<"div">, "title"> & {
  title: ReactNode;
  description?: ReactNode;
  /** Buttons or a menu at the end of the header. */
  actions?: ReactNode;
  /** Turns the title into a link. On an interactive card it stretches over the whole card. */
  href?: string;
};

export function CardHeader({
  title,
  description,
  actions,
  href,
  className,
  ...rest
}: CardHeaderProps) {
  return (
    <div {...rest} className={["eq-card-header", className].filter(Boolean).join(" ")}>
      <div className="eq-card-heading">
        <h3 className="eq-card-title">
          {href ? (
            <a className="eq-card-link" href={href}>
              {title}
            </a>
          ) : (
            title
          )}
        </h3>
        {description != null && <p className="eq-card-description">{description}</p>}
      </div>
      {actions != null && <div className="eq-card-actions">{actions}</div>}
    </div>
  );
}

export function CardBody({ className, ...rest }: ComponentProps<"div">) {
  return <div {...rest} className={["eq-card-body", className].filter(Boolean).join(" ")} />;
}

export function CardFooter({ className, ...rest }: ComponentProps<"div">) {
  return <div {...rest} className={["eq-card-footer", className].filter(Boolean).join(" ")} />;
}

/** A top slot that bleeds to the card's edges and clips to its radius. */
export function CardMedia({ className, ...rest }: ComponentProps<"div">) {
  return <div {...rest} className={["eq-card-media", className].filter(Boolean).join(" ")} />;
}
components/equip/card.css185 lines
@layer equip {
  .eq-card {
    --_pad: var(--eq-space-4);
    --_radius: var(--eq-radius-l);

    position: relative;
    box-sizing: border-box;
    display: flex;
    flex-direction: column;
    gap: var(--_pad);
    min-width: 0;
    margin: 0;
    padding: var(--_pad);
    border: 1px solid var(--eq-line);
    border-radius: var(--_radius);
    background: var(--eq-surface);
    color: var(--eq-ink);
    font: var(--eq-weight) var(--eq-text-m) / 1.5 var(--eq-font);
    list-style: none;
  }

  .eq-card[data-padding="none"] {
    --_pad: 0px;
  }

  .eq-card[data-padding="s"] {
    --_pad: var(--eq-space-3);
  }

  .eq-card[data-padding="l"] {
    --_pad: var(--eq-space-5);
  }

  .eq-card[data-tone="raised"] {
    background: var(--eq-raised);
    box-shadow: var(--eq-shadow);
    -webkit-backdrop-filter: var(--eq-blur);
    backdrop-filter: var(--eq-blur);
  }

  .eq-card[data-tone="outline"] {
    background: transparent;
    border-color: var(--eq-line-strong);
  }

  /* Media bleeds over the padding and keeps the card's corner, one pixel inside the border. */
  .eq-card-media {
    margin: calc(-1 * var(--_pad)) calc(-1 * var(--_pad)) 0;
    overflow: hidden;
    border-bottom: 1px solid var(--eq-line);
    border-radius: calc(var(--_radius) - 1px) calc(var(--_radius) - 1px) 0 0;
  }

  .eq-card-media:not(:first-child) {
    margin-top: 0;
    border-top: 1px solid var(--eq-line);
    border-radius: 0;
  }

  .eq-card-media > :is(img, video, picture, svg) {
    display: block;
    width: 100%;
    height: auto;
  }

  .eq-card-header {
    display: flex;
    align-items: flex-start;
    gap: var(--eq-space-3);
  }

  .eq-card-heading {
    flex: 1;
    min-width: 0;
  }

  .eq-card-title {
    margin: 0;
    font-size: var(--eq-text-m);
    font-weight: var(--eq-weight-strong);
    line-height: 1.4;
    letter-spacing: -0.005em;
  }

  .eq-card-description {
    margin: 0;
    color: var(--eq-ink-2);
    font-size: var(--eq-text-s);
  }

  .eq-card-actions {
    flex: none;
    display: flex;
    gap: var(--eq-space-1);
  }

  .eq-card-body {
    color: var(--eq-ink-2);
  }

  .eq-card-body > :first-child {
    margin-top: 0;
  }

  .eq-card-body > :last-child {
    margin-bottom: 0;
  }

  .eq-card-footer {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: var(--eq-space-2);
    margin-top: auto;
    padding-top: var(--_pad);
    border-top: 1px solid var(--eq-line);
    color: var(--eq-ink-2);
    font-size: var(--eq-text-s);
  }

  .eq-card-link {
    color: inherit;
    text-decoration: none;
    border-radius: var(--eq-radius-s);
  }

  .eq-card-link:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  @media (hover: hover) {
    .eq-card-link:hover {
      text-decoration: underline;
      text-underline-offset: 0.2em;
    }
  }

  /* Interactive: one link, stretched over the card by its pseudo-element. */
  .eq-card[data-interactive] {
    transition:
      translate var(--eq-dur-move) var(--eq-spring),
      scale var(--eq-dur) var(--eq-ease),
      border-color var(--eq-dur) var(--eq-ease);
  }

  .eq-card[data-interactive] .eq-card-link::after {
    content: "";
    position: absolute;
    /* 2px past the bottom edge, so the hover lift does not slide the card out from under the cursor. */
    inset: 0 0 -2px;
    border-radius: var(--_radius);
  }

  /* Nested controls sit above the stretched link, so they keep their own clicks. */
  .eq-card[data-interactive] :is(button, input, select, textarea, summary, a:not(.eq-card-link), [tabindex]) {
    position: relative;
    z-index: 1;
  }

  .eq-card[data-interactive] .eq-card-link:focus-visible {
    outline: none;
  }

  .eq-card[data-interactive]:has(.eq-card-link:focus-visible) {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  @media (hover: hover) {
    .eq-card[data-interactive]:has(.eq-card-link:hover) {
      translate: 0 -2px;
      border-color: var(--eq-line-strong);
    }

    .eq-card[data-interactive] .eq-card-link:hover {
      text-decoration: none;
    }
  }

  .eq-card[data-interactive]:has(.eq-card-link:active) {
    scale: var(--eq-press);
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index