Surface
A composable card-like surface with header and footer slots
Project status
A summary of recent activity across all open issues and pull requests.
import { Button } from '@/components/button';
import { Surface } from '@/components/surface';
export default function SurfacePreview() {
return (
<div className="flex w-full max-w-md flex-col gap-6">
<Surface>
<Surface.Header>
<Surface.Title>Project status</Surface.Title>
<Surface.Description>
A summary of recent activity across all open issues and pull
requests.
</Surface.Description>
<Surface.Action>
<Button variant="ghost" size="sm">
View
</Button>
</Surface.Action>
</Surface.Header>
<Surface.Content minHeight="md">
Compose any content inside Surface.Content. Header, Action, and Footer
are optional — use whichever parts the surface needs.
</Surface.Content>
<Surface.Footer>
<Button variant="outline" size="sm">
Cancel
</Button>
<Button size="sm">Save</Button>
</Surface.Footer>
</Surface>
</div>
);
} Dependencies
Source Code
import type { VariantProps } from 'cva';
import { Slot } from '@/components/slot';
import { cn, cva } from '@/lib/utils/classnames';
const surfaceStyle = cva({
base: 'relative border border-border',
variants: {
/**
* Role-based elevation, after Atlassian + Linear's luminance hierarchy.
* Light mode pairs each step with a matching shadow; dark mode steps
* the surface lightness instead (lighter = higher).
*/
elevation: {
sunken: 'border-transparent bg-surface-sunken shadow-none',
// `default` keeps its border — in light mode it's white-on-white and
// needs the edge. The luminance step alone covers dark mode.
default: 'bg-surface-default shadow-none',
// `raised` and `overlay` rely on the lightness step + paired shadow,
// so the border becomes redundant noise — drop it.
raised: 'border-transparent bg-surface-raised shadow-(--shadow-raised)',
overlay:
'border-transparent bg-surface-overlay shadow-(--shadow-overlay)',
},
radius: {
none: 'rounded-none',
sm: 'rounded-md',
md: 'rounded-xl',
lg: 'rounded-2xl',
xl: 'rounded-3xl',
},
padding: {
none: 'p-0 [--surface-pad:--spacing(0)]',
sm: 'p-3 [--surface-pad:--spacing(3)]',
md: 'p-5 [--surface-pad:--spacing(5)]',
lg: 'p-6 [--surface-pad:--spacing(6)] md:p-8 md:[--surface-pad:--spacing(8)]',
xl: 'p-8 [--surface-pad:--spacing(8)] md:p-12 md:[--surface-pad:--spacing(12)]',
},
interactive: {
true: 'cursor-pointer transition-[transform,background-color] duration-(--duration-hover) ease-(--ease) hover:bg-foreground/2 active:scale-(--press-scale)',
false: '',
},
},
defaultVariants: {
elevation: 'default',
radius: 'md',
padding: 'md',
interactive: false,
},
});
interface SurfaceProps
extends React.ComponentPropsWithRef<'div'>,
VariantProps<typeof surfaceStyle> {
asChild?: boolean;
}
const Surface = ({
ref,
className,
elevation,
radius,
padding,
interactive,
asChild,
...rest
}: SurfaceProps) => {
const Comp = asChild ? Slot : 'div';
return (
<Comp
ref={ref}
className={cn(
surfaceStyle({ elevation, radius, padding, interactive }),
className
)}
{...rest}
/>
);
};
/**
* Header lays out Title + Description in a stack, with an optional Action
* pinned to the right via grid placement. Mirrors shadcn's CardHeader API
* but reads as a Foundations primitive.
*/
const SurfaceHeader = ({
ref,
className,
...rest
}: React.ComponentPropsWithRef<'div'>) => (
<div
ref={ref}
className={cn(
'-mx-(--surface-pad) -mt-(--surface-pad) mb-(--surface-pad) grid auto-rows-min grid-cols-[1fr_auto] items-start gap-x-4 gap-y-1 border-border border-b px-(--surface-pad) py-3.5',
className
)}
{...rest}
/>
);
interface SurfaceTitleProps extends React.ComponentPropsWithRef<'h3'> {
asChild?: boolean;
}
const SurfaceTitle = ({
ref,
className,
asChild,
...rest
}: SurfaceTitleProps) => {
const Comp = asChild ? Slot : 'h3';
return (
<Comp
ref={ref}
className={cn(
'col-start-1 font-semibold text-base text-foreground leading-tight',
className
)}
{...rest}
/>
);
};
const SurfaceDescription = ({
ref,
className,
...rest
}: React.ComponentPropsWithRef<'p'>) => (
<p
ref={ref}
className={cn(
'col-start-1 text-foreground-secondary text-sm leading-snug',
className
)}
{...rest}
/>
);
/**
* Action sits at the right of the Header (column 2, spanning all rows) so it
* stays vertically centered next to a Title-only header but pins to the top
* when a Description is also present. Use for Buttons, Menu triggers, links.
*/
const SurfaceAction = ({
ref,
className,
...rest
}: React.ComponentPropsWithRef<'div'>) => (
<div
ref={ref}
data-slot="surface-action"
className={cn(
'col-start-2 row-span-1 row-start-1 flex shrink-0 items-center gap-2 self-center',
className
)}
{...rest}
/>
);
const surfaceContentStyle = cva({
base: 'text-foreground text-sm leading-relaxed',
variants: {
/**
* Reserves a baseline height for the body so sparse cards keep their shape
* and rows of cards align cleanly. Steps map to common dashboard tile
* heights — pick the one that matches the densest tile in the row.
*/
minHeight: {
none: '',
sm: 'min-h-20',
md: 'min-h-32',
lg: 'min-h-48',
xl: 'min-h-64',
},
},
defaultVariants: {
minHeight: 'none',
},
});
interface SurfaceContentProps
extends React.ComponentPropsWithRef<'div'>,
VariantProps<typeof surfaceContentStyle> {}
/**
* Content is the main body. It exists mainly for parity with the shadcn
* vocabulary — without it, consumers had to know that a bare `<p>` works.
* No padding of its own (Surface already pads); just resets prose styling
* and (optionally) reserves a baseline height via `minHeight`.
*/
const SurfaceContent = ({
ref,
className,
minHeight,
...rest
}: SurfaceContentProps) => (
<div
ref={ref}
className={cn(surfaceContentStyle({ minHeight }), className)}
{...rest}
/>
);
const SurfaceFooter = ({
ref,
className,
...rest
}: React.ComponentPropsWithRef<'div'>) => (
<div
ref={ref}
className={cn(
'-mx-(--surface-pad) mt-(--surface-pad) -mb-(--surface-pad) flex items-center justify-end gap-2 border-border border-t px-(--surface-pad) py-3',
className
)}
{...rest}
/>
);
const CompoundSurface = Object.assign(Surface, {
Header: SurfaceHeader,
Title: SurfaceTitle,
Description: SurfaceDescription,
Action: SurfaceAction,
Content: SurfaceContent,
Footer: SurfaceFooter,
});
export type { SurfaceContentProps, SurfaceProps, SurfaceTitleProps };
export { CompoundSurface as Surface, surfaceStyle }; Server component. No
'use client'— elevation surface wrapper; cva + Slot only, no hooks or own handlers.
Surface is the canvas you build cards, panels, and dashboard tiles on top of.
It is intentionally low-opinion: pick a variant, radius, and padding to
fit the surrounding context.
Anatomy
<Surface>
<Surface.Header>
<Surface.Title>Title</Surface.Title>
<Surface.Description>Optional supporting copy</Surface.Description>
<Surface.Action>{actionsOrMenu}</Surface.Action>
</Surface.Header>
<Surface.Content>{body}</Surface.Content>
<Surface.Footer>{footerActions}</Surface.Footer>
</Surface>
Every part is optional — drop in only the slots you need. Surface.Action is
positioned by Surface.Header (right column, vertically centered) so it sits
correctly whether or not a Description is present.
API Reference
Surface
Extends the div element.
| Prop | Default | Type |
|---|---|---|
elevation | "default" | "sunken""default""raised""overlay" |
radius | "md" | "none""sm""md""lg""xl" |
padding | "md" | "none""sm""md""lg""xl" |
interactive | - | boolean |
asChild | - | boolean |
elevation is role-based, after Atlassian and Linear:
- sunken — page surface “well” under inset cards / panels
- default — flat surfaces (the canvas), no shadow
- raised — cards stacked on default; pairs with a subtle shadow
- overlay — popovers, menus, nested cards; pairs with a stronger shadow
Light mode shows depth via paired shadows; dark mode shows depth via luminance (each step is ~3% L lighter than the one below it).
Surface.Header
Extends the div element. Lays Title, Description, and Action out in a 2-column
grid: text stacks in the left column, Action pins to the right column. Sits
flush against the surface edges via --surface-pad.
Surface.Title
Extends the h3 element. Use asChild to swap the heading level.
| Prop | Default | Type |
|---|---|---|
asChild | - | boolean |
Surface.Description
Extends the p element.
Surface.Action
Extends the div element. Place inside Surface.Header next to the Title /
Description.
Surface.Content
Extends the div element. The main body — exists so consumers don’t reach for
a bare <p> or <div> and get unstyled prose. Pass minHeight to reserve a
baseline height; useful for keeping a row of cards visually aligned when each
has different amounts of content.
| Prop | Default | Type |
|---|---|---|
minHeight | "none" | "none""sm""md""lg""xl" |
Surface.Footer
Extends the div element. Sits flush against the bottom edges via
--surface-pad and is right-aligned for action buttons by default.
Examples
Default
Project status
A summary of recent activity across all open issues and pull requests.
import { Button } from '@/components/button';
import { Surface } from '@/components/surface';
export default function SurfacePreview() {
return (
<div className="flex w-full max-w-md flex-col gap-6">
<Surface>
<Surface.Header>
<Surface.Title>Project status</Surface.Title>
<Surface.Description>
A summary of recent activity across all open issues and pull
requests.
</Surface.Description>
<Surface.Action>
<Button variant="ghost" size="sm">
View
</Button>
</Surface.Action>
</Surface.Header>
<Surface.Content minHeight="md">
Compose any content inside Surface.Content. Header, Action, and Footer
are optional — use whichever parts the surface needs.
</Surface.Content>
<Surface.Footer>
<Button variant="outline" size="sm">
Cancel
</Button>
<Button size="sm">Save</Button>
</Surface.Footer>
</Surface>
</div>
);
} Previous
Stat
Next
Switch