cards
card
ready-made cards in two layouts: split (title and picture side by side) and stack (picture on top).
the quiet art of long-form
everything we write
t
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 cardor by hand: drop these files into components/ui and import from there. card leans on CardShell.tsx, so those come along too.
components/ui/Card.tsxtsx
1import React from "react";2import { CardShell } from "./CardShell";3
4type CardTheme = "light" | "dark";5
6interface SplitCardProps {7 title?: string;8 subtitle?: string;9 icon?: React.ReactNode;10 image?: React.ReactNode;11 imageSrc?: string;12 imageAlt?: string;13 actions?: React.ReactNode;14 theme?: CardTheme;15 className?: string;16 children?: React.ReactNode;17 layout?: "split";18}19
20interface StackCardProps {21 title?: string;22 badge?: React.ReactNode;23 subtitle?: string;24 note?: string;25 image?: React.ReactNode;26 imageSrc?: string;27 imageAlt?: string;28 imageBg?: string;29 actions?: React.ReactNode;30 className?: string;31 children?: React.ReactNode;32 layout: "stack";33}34
35type CardProps = SplitCardProps | StackCardProps;36
37/* Both layouts use CardShell: content on the face, actions on the rail.38 Media sits flush in the face — the face's own corners frame it, so there39 is no box-inside-a-box. */40
41function MediaPlaceholder({ dark, bg, monogram }: { dark?: boolean; bg?: string; monogram?: string }) {42 return (43 <div44 className={["relative w-full h-full flex items-center justify-center", bg ?? ""].join(" ")}45 style={bg ? undefined : { background: dark ? "var(--media-well-dark)" : "var(--media-well-light)" }}46 >47 {/* A faint dot grid fading out from the centre: a slot, not a hole */}48 <div49 aria-hidden50 className="absolute inset-0"51 style={{52 backgroundImage: `radial-gradient(${53 bg ? "rgba(255,255,255,0.22)" : dark ? "rgba(255,255,255,0.09)" : "rgba(17,17,17,0.09)"54 } 1px, transparent 1px)`,55 backgroundSize: "12px 12px",56 maskImage: "radial-gradient(70% 70% at 50% 50%, #000 0%, transparent 100%)",57 WebkitMaskImage: "radial-gradient(70% 70% at 50% 50%, #000 0%, transparent 100%)",58 }}59 />60 {/* The title's initial, set huge and barely there and cropped by the61 slot's edges: the empty half carries the card's type, not a glyph */}62 {monogram && (63 <span64 aria-hidden65 className={[66 "absolute -right-3 -bottom-[0.32em] text-[220px] font-semibold leading-none tracking-[-0.06em] select-none",67 dark ? "text-white/[0.045]" : "text-neutral-900/[0.045]",68 ].join(" ")}69 >70 {monogram}71 </span>72 )}73 </div>74 );75}76
77function Media({78 image,79 imageSrc,80 imageAlt,81 dark,82 bg,83 monogram,84}: {85 image?: React.ReactNode;86 imageSrc?: string;87 imageAlt: string;88 dark?: boolean;89 bg?: string;90 monogram?: string;91}) {92 if (image) return <>{image}</>;93 if (imageSrc) {94 // eslint-disable-next-line @next/next/no-img-element95 return <img src={imageSrc} alt={imageAlt} loading="lazy" className="w-full h-full object-cover" />;96 }97 return <MediaPlaceholder dark={dark} bg={bg} monogram={monogram} />;98}99
100function IconTile({ dark, children }: { dark: boolean; children: React.ReactNode }) {101 return (102 <div103 aria-hidden104 className={[105 "w-9 h-9 rounded-[11px] flex items-center justify-center shrink-0",106 dark ? "text-neutral-100" : "text-neutral-700",107 ].join(" ")}108 style={{109 background: dark ? "var(--tile-dark)" : "var(--face-light)",110 boxShadow: dark ? "var(--tile-dark-shadow)" : "var(--tile-light-shadow)",111 }}112 >113 {children}114 </div>115 );116}117
118export function Card(props: CardProps) {119 if (props.layout === "stack") {120 const {121 title,122 badge,123 subtitle,124 note,125 image,126 imageSrc,127 imageAlt = "",128 imageBg,129 actions,130 className = "",131 children,132 } = props;133
134 return (135 <CardShell136 as="article"137 className={className}138 rail={actions}139 >140 {/* Hairline under the media so it reads as set into the face */}141 <div className="relative h-52 shrink-0 overflow-hidden" style={{ boxShadow: "0 1px 0 rgba(17,17,17,0.07)" }}>142 <Media image={image} imageSrc={imageSrc} imageAlt={imageAlt} bg={imageBg} />143 </div>144
145 <div className="flex-1 flex flex-col p-6">146 {(title || badge) && (147 <div className="flex items-center gap-2">148 {title && (149 <h3 className="text-[17px] font-semibold text-neutral-900 tracking-[-0.015em] leading-snug">{title}</h3>150 )}151 {badge}152 </div>153 )}154 {subtitle && (155 // Line breaks in the copy are for centred layouts; let it flow here156 <p className="mt-1.5 text-[14px] text-neutral-600 leading-[1.55] text-pretty">157 {subtitle.replace(/\n/g, " ")}158 </p>159 )}160 {children}161 {note && (162 <p className="mt-auto pt-5 text-[12px] text-neutral-500 leading-relaxed text-pretty">{note}</p>163 )}164 </div>165 </CardShell>166 );167 }168
169 const {170 title,171 subtitle,172 icon,173 image,174 imageSrc,175 imageAlt = "",176 actions,177 theme = "light",178 className = "",179 children,180 } = props as SplitCardProps;181 const dark = theme === "dark";182
183 return (184 <CardShell185 as="article"186 theme={dark ? "dark" : "light"}187 className={className}188 faceClassName="flex-row! min-h-[220px]"189 rail={actions}190 >191 {/* The title is the hero: display-sized and pinned to the top. The192 subtitle answers it from the bottom edge, so the two frame the193 face between them instead of huddling in one corner. */}194 <div className="relative flex flex-col p-7 flex-1 min-w-0">195 {icon && <IconTile dark={dark}>{icon}</IconTile>}196 {title && (197 <h3198 className={[199 "text-[34px] font-medium leading-[1.02] tracking-[-0.04em] text-balance max-w-[14ch]",200 icon ? "mt-5" : "",201 dark ? "text-white" : "text-neutral-900",202 ].join(" ")}203 >204 {title}205 </h3>206 )}207 <div className="mt-auto pt-8 flex flex-col gap-2">208 {subtitle && (209 <p className="text-[14px] leading-[1.45] tracking-[-0.005em] text-neutral-500">{subtitle}</p>210 )}211 {children}212 </div>213 </div>214 <div215 className="relative w-[40%] shrink-0 overflow-hidden"216 style={{ boxShadow: dark ? "-1px 0 0 rgba(255,255,255,0.07)" : "-1px 0 0 rgba(17,17,17,0.07)" }}217 >218 <Media219 image={image}220 imageSrc={imageSrc}221 imageAlt={imageAlt}222 dark={dark}223 monogram={typeof title === "string" ? title.trim().charAt(0).toLowerCase() : undefined}224 />225 </div>226 </CardShell>227 );228}229
230interface CardGridProps {231 children: React.ReactNode;232 className?: string;233}234
235export function CardGrid({ children, className = "" }: CardGridProps) {236 return (237 <div className={["grid grid-cols-1 sm:grid-cols-2 gap-4", className].join(" ")}>238 {children}239 </div>240 );241}components/ui/CardShell.tsxtsx
1import React from "react";2
3type Theme = "light" | "dark";4
5interface CardShellProps {6 theme?: Theme;7 /** Controls and context: actions, attribution, deltas. Lives on the chassis. */8 rail?: React.ReactNode;9 /** "actions" for buttons; "text" for plain context, indented to the face's text edge */10 railKind?: "actions" | "text";11 as?: "div" | "figure" | "article";12 className?: string;13 faceClassName?: string;14 children: React.ReactNode;15}16
17/* Every card is built like every other object in the kit: a chassis (bezel)18 holding a face. Content goes on the face; controls and context go on the19 rail, the strip of chassis below it. The two are nearly the same tone —20 the hairline around the face is what separates them.21
22 Geometry, measured from the card's outer edge:23 - chassis 24px radius, 6px bezel → face 18px radius (concentric)24 - rail content sits 12px in from the left and bottom, 10px below the face25 - a button's label then starts 30px in — the same edge as the face's text,26 and plain-text rails are indented to that edge too */27export function CardShell({28 theme = "light",29 rail,30 railKind = "actions",31 as: Tag = "div",32 className = "",33 faceClassName = "",34 children,35}: CardShellProps) {36 const dark = theme === "dark";37
38 return (39 <Tag40 className={[41 "rim relative m-0 rounded-[24px] p-1.5 flex flex-col h-full",42 rail ? "pb-0" : "",43 className,44 ].join(" ")}45 style={{46 background: dark ? "var(--card-bezel-dark)" : "var(--card-bezel-light)",47 boxShadow: dark ? "var(--card-dark-shadow)" : "var(--card-light-shadow)",48 ["--rim" as string]: dark ? "rgba(255,255,255,0.1)" : "rgba(255,255,255,1)",49 } as React.CSSProperties}50 >51 <div52 className={["relative flex-1 min-h-0 rounded-[18px] overflow-hidden flex flex-col", faceClassName].join(" ")}53 style={{54 background: dark ? "var(--card-dark)" : "var(--card-light)",55 boxShadow: dark ? "var(--card-face-dark-shadow)" : "var(--card-face-light-shadow)",56 }}57 >58 {children}59 </div>60
61 {rail && (62 <div className="relative shrink-0 px-1.5 pt-2.5 pb-3">63 <div64 className={[65 // 40px = a small button's full height, so text rails and button66 // rails come out the same height and line up across a row67 "min-h-10 flex items-center gap-2 text-[12.5px]",68 railKind === "text" ? "pl-[18px] pr-3 gap-2.5" : "",69 dark ? "text-neutral-400" : "text-neutral-500",70 ].join(" ")}71 >72 {rail}73 </div>74 </div>75 )}76 </Tag>77 );78}usage
tsx
import { Card, CardGrid } from "@blurrow/ui";tsx
<Card title="newsletter" subtitle="ideas in your inbox" actions={<Button size="sm">subscribe</Button>} />examples
stack
blurrow
osstactile components for people who care how things feel.
built with tailwind css v4 and archivo.
grid
cardgrid lays cards out two-up and stacks them on small screens.
newsletter
ideas in your inbox
n
rss feed
follow along anywhere
r
props
straight from the source, so they're always up to date.
propsts
type CardTheme = "light" | "dark";
type CardProps = SplitCardProps | StackCardProps;
interface SplitCardProps { title?: string; subtitle?: string; icon?: React.ReactNode; image?: React.ReactNode; imageSrc?: string; imageAlt?: string; actions?: React.ReactNode; theme?: CardTheme; className?: string; children?: React.ReactNode; layout?: "split";}
interface StackCardProps { title?: string; badge?: React.ReactNode; subtitle?: string; note?: string; image?: React.ReactNode; imageSrc?: string; imageAlt?: string; imageBg?: string; actions?: React.ReactNode; className?: string; children?: React.ReactNode; layout: "stack";}
interface CardGridProps { children: React.ReactNode; className?: string;}