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.
$48/ month
4 of 5 seats used
Atlas field reports
How a survey team cut a day of reporting down to an hour.
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.
Install
npm i mihirmodi-ui motionThe 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
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
| Prop | Type | Notes |
|---|---|---|
| interactiveboolean | boolean | Lift and deepen on hover. Implied by `to` or `href`. |
| to / hrefstring | string | Render the card as a link — `to` routes through your link component. |
| glassboolean | boolean | Frost it, for a card that sits over imagery. |
| CardHeader actionReactNode | ReactNode | A badge or menu pinned to the header's top-right. |
| CardFooter dividedboolean | boolean | A 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.
"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}
/>
);
}