cards

spotlight card

a big card for announcements, with room for an eyebrow, an icon and a couple of buttons.

new

realtime collaboration is here.

invite an editor and review drafts together before they go out.

installation

new to blurrow? this sets up your project in one go: the package, the styles and the font. already done it? skip straight to usage. the installation page has the details.

bash
npx @blurrow/ui init
›rather own the code? copy the source instead

let the cli copy it, along with anything it needs:

bash
npx @blurrow/ui add spotlight-card

or by hand: drop these files into components/ui and import from there. spotlight card leans on CardShell.tsx, so those come along too.

components/ui/SpotlightCard.tsxtsx
import React from "react";
import { CardShell } from "./CardShell";
interface SpotlightCardProps {
title: string;
subtitle?: string;
eyebrow?: string;
icon?: React.ReactNode;
actions?: React.ReactNode;
children?: React.ReactNode;
theme?: "light" | "dark";
size?: "sm" | "md" | "lg";
className?: string;
}
const paddingBySize: Record<NonNullable<SpotlightCardProps["size"]>, string> = {
// Feature cards pad 32px whatever their size; only the type scales
sm: "p-6",
md: "p-8",
lg: "p-8",
};
const titleSizeBySize: Record<
NonNullable<SpotlightCardProps["size"]>,
string
> = {
sm: "text-[20px]",
md: "text-[26px]",
lg: "text-[34px]",
};
export function SpotlightCard({
title,
subtitle,
eyebrow,
icon,
actions,
children,
theme = "dark",
size = "md",
className = "",
}: SpotlightCardProps) {
const dark = theme === "dark";
return (
/* Built on CardShell: icon at the top of the face, words and actions
settled at the bottom — the same rhythm as Card and QuoteCard. */
<CardShell as="article" theme={theme} className={className}>
{/* Headline sits low on the face, so tall cards breathe above it, not below */}
<div className={["relative flex-1 flex flex-col gap-3", paddingBySize[size]].join(" ")}>
{icon && (
<div
className={[
"w-9 h-9 rounded-[11px] flex items-center justify-center mb-auto",
dark ? "text-white" : "text-neutral-800",
].join(" ")}
style={{
background: dark ? "var(--tile-dark)" : "var(--face-light)",
boxShadow: dark ? "var(--tile-dark-shadow)" : "var(--tile-light-shadow)",
}}
>
{icon}
</div>
)}
{eyebrow && (
<p
className={[
// Without an icon above to push it down, the copy still sits low
icon ? "pt-8" : "mt-auto",
"text-[14px] font-medium tracking-[-0.01em]",
dark ? "text-neutral-400" : "text-neutral-500",
].join(" ")}
>
{eyebrow}
</p>
)}
<h3
className={[
titleSizeBySize[size],
!icon && !eyebrow ? "mt-auto" : "",
"font-medium leading-[1.12] tracking-[-0.03em] text-balance",
dark ? "text-white" : "text-neutral-900",
].join(" ")}
>
{title}
</h3>
{subtitle && (
<p
className={[
"text-[15px] leading-[1.6] max-w-lg text-pretty",
dark ? "text-neutral-400" : "text-neutral-600",
].join(" ")}
>
{subtitle}
</p>
)}
{children}
{actions && <div className="pt-4 flex items-center gap-2">{actions}</div>}
</div>
</CardShell>
);
}
components/ui/CardShell.tsxtsx
import React from "react";
type Theme = "light" | "dark";
interface CardShellProps {
theme?: Theme;
/** Controls and context: actions, attribution, deltas. Lives on the chassis. */
rail?: React.ReactNode;
/** "actions" for buttons; "text" for plain context, indented to the face's text edge */
railKind?: "actions" | "text";
as?: "div" | "figure" | "article";
className?: string;
faceClassName?: string;
children: React.ReactNode;
}
/* Every card is built like every other object in the kit: a chassis (bezel)
holding a face. Content goes on the face; controls and context go on the
rail, the strip of chassis below it. The two are nearly the same tone —
the hairline around the face is what separates them.
Geometry, measured from the card's outer edge:
- chassis 24px radius, 6px bezel → face 18px radius (concentric)
- rail content sits 12px in from the left and bottom, 10px below the face
- a button's label then starts 30px in — the same edge as the face's text,
and plain-text rails are indented to that edge too */
export function CardShell({
theme = "light",
rail,
railKind = "actions",
as: Tag = "div",
className = "",
faceClassName = "",
children,
}: CardShellProps) {
const dark = theme === "dark";
return (
<Tag
className={[
"rim relative m-0 rounded-[24px] p-1.5 flex flex-col h-full",
rail ? "pb-0" : "",
className,
].join(" ")}
style={{
background: dark ? "var(--card-bezel-dark)" : "var(--card-bezel-light)",
boxShadow: dark ? "var(--card-dark-shadow)" : "var(--card-light-shadow)",
["--rim" as string]: dark ? "rgba(255,255,255,0.1)" : "rgba(255,255,255,1)",
} as React.CSSProperties}
>
<div
className={["relative flex-1 min-h-0 rounded-[18px] overflow-hidden flex flex-col", faceClassName].join(" ")}
style={{
background: dark ? "var(--card-dark)" : "var(--card-light)",
boxShadow: dark ? "var(--card-face-dark-shadow)" : "var(--card-face-light-shadow)",
}}
>
{children}
</div>
{rail && (
<div className="relative shrink-0 px-1.5 pt-2.5 pb-3">
<div
className={[
// 40px = a small button's full height, so text rails and button
// rails come out the same height and line up across a row
"min-h-10 flex items-center gap-2 text-[12.5px]",
railKind === "text" ? "pl-[18px] pr-3 gap-2.5" : "",
dark ? "text-neutral-400" : "text-neutral-500",
].join(" ")}
>
{rail}
</div>
</div>
)}
</Tag>
);
}

usage

tsx
import { SpotlightCard } from "@blurrow/ui";
tsx
<SpotlightCard title="realtime collaboration is here." subtitle="…" actions={…} />

examples

light

version history

every save is a snapshot. roll back to any of them.

props

straight from the source, so they're always up to date.

propsts
interface SpotlightCardProps {
title: string;
subtitle?: string;
eyebrow?: string;
icon?: React.ReactNode;
actions?: React.ReactNode;
children?: React.ReactNode;
theme?: "light" | "dark";
size?: "sm" | "md" | "lg";
className?: string;
}