← All components

Card

A hairline round one thing, flat at rest, that lifts a pixel only when it can be pressed.

Team plan

Renews on 1 November.

Active

$48/ month

4 of 5 seats used

Atlas field reports

How a survey team cut a day of reporting down to an hour.

Case study · 8 min

The idea

Cards are the exception on a page, not its frame, so a card at rest is just a hairline and the page's own white — anything heavier turns a grid of them into a wall of tiles before a word is read. Only a card you can press earns depth, and only while you point at it: it rises a pixel and its shadow opens into the same three layers the floating chrome uses. Glass is for the other case, a card over a photograph.

Composes Button and Badge.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

Card — usage24 lines
import { Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "mihirmodi-ui";

// Flat at rest: a hairline and the page's white. No resting shadow.
<Card>
  <CardHeader>
    <CardTitle>Team plan</CardTitle>
    <CardDescription>Renews on 1 November.</CardDescription>
  </CardHeader>
  <CardContent>$48 / month</CardContent>
  <CardFooter divided>
    <Button size="sm" variant="primary">Add a seat</Button>
  </CardFooter>
</Card>

// A card that IS a link lifts a pixel under the pointer.
<Card to="/work/atlas">
  <CardHeader>
    <CardTitle>Atlas field reports</CardTitle>
    <CardDescription>A day of reporting, down to an hour.</CardDescription>
  </CardHeader>
</Card>

// Over a photograph, frost it instead.
<Card glass>…</Card>

Props

PropNotes
interactivebooleanLift and deepen on hover. Implied by `to` or `href`.
to / hrefstringRender the card as a link — `to` routes through your link component.
glassbooleanFrost it, for a card that sits over imagery.
CardHeader actionReactNodeA badge or menu pinned to the header's top-right.
CardFooter dividedbooleanA hairline above the footer, for actions that belong to the whole card.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/layout/Card.tsx137 lines
"use client";

import type { HTMLAttributes, ReactNode } from "react";
import { useLinkComponent, type LinkComponent } from "../../lib/link.js";

export type CardProps = HTMLAttributes<HTMLDivElement> & {
  /** Frost it, for a card that sits over a photograph or a busy canvas rather than on the page. */
  glass?: boolean;
  /**
   * The whole card is one target. It lifts a pixel under the pointer and its
   * shadow deepens. Pair with `to` / `href`, or handle `onClick` yourself.
   */
  interactive?: boolean;
  /** An in-app destination, routed through your link component. Implies `interactive`. */
  to?: string;
  /** A plain link — off-site, a file. Implies `interactive`. */
  href?: string;
  /** Override the routed link component for this card only. */
  link?: LinkComponent;
};

const SURFACE = "relative flex flex-col gap-4 rounded-2xl border-[0.5px] py-5 text-gray-1000";
const PLAIN = "border-gray-alpha-400 bg-background-100";
const GLASS = "glass-menu dock-shadow border-gray-alpha-400";

/*
 * Every property the hover touches is named, so the card eases back down on
 * leave as well as up on enter. The rest shadow is the same three layers at
 * nothing, so the deeper one interpolates instead of popping in.
 */
const LIFT =
  "transition-[transform,box-shadow,border-color] duration-150 ease-geist " +
  "hover:-translate-y-px hover:border-gray-alpha-600 " +
  "active:translate-y-0 motion-reduce:transition-[box-shadow,border-color] motion-reduce:hover:translate-y-0";
const GLASS_HOVER = "transition-[border-color] duration-150 ease-out hover:border-gray-alpha-600";
/* Plain cards only: a glass card already floats on `dock-shadow`, and a
   shadow utility here would replace its lit rim rather than add to it. */
const DEEPEN =
  "shadow-[0_1px_1px_rgba(0,0,0,0.02),0_8px_16px_-4px_rgba(0,0,0,0),0_24px_32px_-8px_rgba(0,0,0,0)] " +
  "hover:shadow-[0_1px_1px_rgba(0,0,0,0.03),0_8px_16px_-4px_rgba(0,0,0,0.06),0_24px_32px_-8px_rgba(0,0,0,0.08)] " +
  "dark:hover:shadow-[0_1px_1px_rgba(0,0,0,0.3),0_8px_16px_-4px_rgba(0,0,0,0.5),0_24px_32px_-8px_rgba(0,0,0,0.6)]";

/**
 * A surface that holds one thing together — a plan, a project, a person.
 *
 * Cards are the exception on a page, not its frame. A hairline and the page's
 * own white is all one needs: the edge says "these belong together", and
 * anything heavier — a fill, a resting shadow — makes a grid of them read as a
 * wall of tiles before anyone has read a word. So a card at rest is flat.
 *
 * Only a card you can press earns depth, and only while you point at it: it
 * rises one pixel and its shadow opens out into the same three layers the
 * floating chrome uses, so the lift reads as the card coming toward you rather
 * than a border getting darker. `glass` is for the other case, a card over a
 * photograph, where the page's white would be a hole in the picture.
 *
 *     <Card interactive to="/plans/team">
 *       <CardHeader>
 *         <CardTitle>Team</CardTitle>
 *         <CardDescription>For five to fifty people.</CardDescription>
 *       </CardHeader>
 *       <CardContent>…</CardContent>
 *     </Card>
 */
export function Card({ glass, interactive, to, href, link, className = "", children, ...rest }: CardProps) {
  const Link = useLinkComponent(link);
  const lifts = interactive || !!to || !!href;
  // A glass card does not move: a transform on the element that carries the
  // backdrop-filter weakens the blur in most engines. It answers with its edge.
  const hover = !lifts ? "" : glass ? GLASS_HOVER : `${LIFT} ${DEEPEN}`;
  const cls = `${SURFACE} ${glass ? GLASS : PLAIN} ${hover} ${className}`;
  if (to || href) {
    const Anchor = to ? Link : "a";
    return (
      <Anchor href={(to ?? href) as string} className={cls} {...(rest as HTMLAttributes<HTMLAnchorElement>)}>
        {children}
      </Anchor>
    );
  }
  return (
    <div className={cls} {...rest}>
      {children}
    </div>
  );
}

export type CardHeaderProps = HTMLAttributes<HTMLDivElement> & {
  /** A control pinned to the header's top-right — a menu, a badge. */
  action?: ReactNode;
};

/** Title and description, with an optional action held to the right. */
export function CardHeader({ action, className = "", children, ...rest }: CardHeaderProps) {
  return (
    <div className={`flex items-start gap-3 px-5 ${className}`} {...rest}>
      <div className="flex min-w-0 flex-1 flex-col gap-1">{children}</div>
      {action && <div className="-mr-1 -mt-1 flex shrink-0 items-center">{action}</div>}
    </div>
  );
}

export type CardTitleProps = HTMLAttributes<HTMLHeadingElement> & {
  /** The heading level. A card's title is rarely the page's second-level heading. */
  as?: "h2" | "h3" | "h4" | "p";
};

/** 14px at 500 — a sub-heading on the ladder, so a card never outranks the section it sits in. */
export function CardTitle({ as: Tag = "h3", className = "", ...rest }: CardTitleProps) {
  return <Tag className={`text-[14px] font-medium leading-[1.4] text-gray-1000 ${className}`} {...rest} />;
}

/** One line of context under the title, in the muted tier. */
export function CardDescription({ className = "", ...rest }: HTMLAttributes<HTMLParagraphElement>) {
  return <p className={`text-[13px] leading-[1.5] text-muted ${className}`} {...rest} />;
}

/** The body. The card spaces its parts, so this only carries the sides. */
export function CardContent({ className = "", ...rest }: HTMLAttributes<HTMLDivElement>) {
  return <div className={`px-5 text-[14px] leading-[1.6] ${className}`} {...rest} />;
}

export type CardFooterProps = HTMLAttributes<HTMLDivElement> & {
  /** A hairline above the footer, for when it holds actions that belong to the whole card. */
  divided?: boolean;
};

/** A row along the bottom — actions, a meta line. Closes the card's padding. */
export function CardFooter({ divided, className = "", ...rest }: CardFooterProps) {
  return (
    <div
      className={`mt-auto flex items-center gap-2 px-5 ${
        divided ? "border-t-[0.5px] border-gray-alpha-400 pt-4" : ""
      } ${className}`}
      {...rest}
    />
  );
}