cards
quote card
a quote, with who said it on the strip below.
the cleanest toolkit i've touched in years. it stays out of the way while i write.
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 quote-cardor by hand: drop these files into components/ui and import from there. quote card leans on CardShell.tsx, so those come along too.
components/ui/QuoteCard.tsxtsx
1import React from "react";2import { CardShell } from "./CardShell";3
4interface QuoteCardProps {5 quote: string;6 author: string;7 role?: string;8 avatarSrc?: string;9 theme?: "light" | "dark";10 className?: string;11}12
13/* Built on CardShell: the quote owns the face; who said it sits on the rail,14 so a stretched card grows the face instead of opening a gap mid-card. */15export function QuoteCard({16 quote,17 author,18 role,19 avatarSrc,20 theme = "light",21 className = "",22}: QuoteCardProps) {23 const dark = theme === "dark";24 // Handles like "@name" shouldn't render an "@" monogram25 const initial = (author.match(/[\p{L}\p{N}]/u)?.[0] ?? "?").toLowerCase();26
27 const rail = (28 <figcaption className="flex items-center gap-2.5 min-w-0">29 {avatarSrc ? (30 // eslint-disable-next-line @next/next/no-img-element31 <img32 src={avatarSrc}33 alt=""34 loading="lazy"35 className="w-7 h-7 rounded-full object-cover shrink-0"36 style={{ boxShadow: dark ? "0 0 0 0.5px rgba(255,255,255,0.084)" : "0 0 0 0.5px rgba(17,17,17,0.048)" }}37 />38 ) : (39 <span40 aria-hidden41 className={[42 "w-7 h-7 rounded-full flex items-center justify-center text-[11.5px] font-semibold shrink-0",43 dark ? "text-white" : "text-neutral-700",44 ].join(" ")}45 style={{46 background: dark ? "var(--tile-dark)" : "var(--face-light)",47 boxShadow: dark ? "var(--tile-dark-shadow)" : "var(--tile-light-shadow)",48 }}49 >50 {initial}51 </span>52 )}53 <span className="min-w-0 truncate">54 <cite55 className={[56 "not-italic font-semibold tracking-[-0.008em]",57 dark ? "text-white" : "text-neutral-900",58 ].join(" ")}59 >60 {author}61 </cite>62 {role && <span> · {role}</span>}63 </span>64 </figcaption>65 );66
67 return (68 <CardShell as="figure" theme={theme} className={className} rail={rail} railKind="text">69 <div className="flex-1 flex flex-col p-8">70 <svg71 aria-hidden72 width="22"73 height="17"74 viewBox="0 0 56 44"75 className={["shrink-0 mb-auto", dark ? "text-white/25" : "text-neutral-300"].join(" ")}76 >77 <path78 fill="currentColor"79 d="M0 44V27.5C0 12.6 7.4 3.4 22.2 0l2.6 6.2C16.9 8.7 13 13.7 12.6 21H24v23H0zm32 0V27.5C32 12.6 39.4 3.4 54.2 0l2.6 6.2C48.9 8.7 45 13.7 44.6 21H56v23H32z"80 />81 </svg>82 <blockquote83 className={[84 "m-0 pt-8 text-[19px] leading-[1.55] tracking-[-0.015em] text-pretty",85 dark ? "text-neutral-50" : "text-neutral-900",86 ].join(" ")}87 >88 {quote}89 </blockquote>90 </div>91 </CardShell>92 );93}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 { QuoteCard } from "@blurrow/ui";tsx
<QuoteCard quote="…" author="ada lovelace" role="analyst" />examples
dark
we replaced half our design system with this in a weekend.
props
straight from the source, so they're always up to date.
propsts
interface QuoteCardProps { quote: string; author: string; role?: string; avatarSrc?: string; theme?: "light" | "dark"; className?: string;}