# Blog cards

> Four ways to present a post — editorial cover, compact list, featured series and author-led quote — each with its own motion moment, from layered parallax to a gliding row highlight.

- Collection: Examples
- Tier: free
- Docs: https://geturui.com/docs/blog-cards
- Install: `npx shadcn@latest add @geturui/blog-cards`
- Dependencies: motion, lucide-react

## Variants

### Editorial

Cover card with a drawn type specimen whose layers drift under the pointer, plus category, excerpt and byline.

Install: `npx shadcn@latest add @geturui/blog-cards-editorial`

```tsx
import { EditorialBlogCard } from "@/components/examples/blog-cards/editorial";

export function Story() {
  return <EditorialBlogCard category="Typography" title="The quiet return of serif type" author="Iris Calder" date="2026-09-28" readingMinutes={8} href="/journal/serif-type" />;
}
```

| Prop | Type | Default | Description |
|---|---|---|---|
| category / title / excerpt | `string` | — | Post content. |
| author / monogram | `string` | — | Byline and avatar initials (derived from author when omitted). |
| date | `string` | "2026-09-28" | ISO date, formatted as Sep 28, 2026. |
| readingMinutes | `number` | 8 | Reading time in minutes. |
| issue | `string` | "Issue 14" | Label printed on the cover. |
| href / onOpen | `string / () => void` | — | Destination and click handler for the card. |
| className | `string` | — | Extra classes on the root element. |

### Compact list

List of posts with reading-progress rings, a highlight that glides between rows and arrow-key navigation.

Install: `npx shadcn@latest add @geturui/blog-cards-compact-list`

```tsx
import { CompactBlogList } from "@/components/examples/blog-cards/compact-list";

export function Sidebar({ posts }) {
  return <CompactBlogList heading="Keep reading" posts={posts} allHref="/blog" />;
}
```

| Prop | Type | Default | Description |
|---|---|---|---|
| heading | `string` | "Latest from the journal" | Title above the list. |
| posts | `CompactPost[]` | — | { id, title, category, date, readingMinutes, progress?, href? } per row. |
| allHref | `string` | "#" | Link for View all. |
| onSelect | `(post: CompactPost) => void` | — | Called when a row is opened. |
| className | `string` | — | Extra classes on the root element. |

### Featured series

Split card for a multi-part series: step through parts while the art and copy crossfade in the direction of travel.

Install: `npx shadcn@latest add @geturui/blog-cards-featured-split`

```tsx
import { FeaturedBlogCard } from "@/components/examples/blog-cards/featured-split";

export function Series() {
  return <FeaturedBlogCard series="Building a design system in public" parts={parts} onRead={(part) => router.push(part.href)} />;
}
```

| Prop | Type | Default | Description |
|---|---|---|---|
| eyebrow / series | `string` | — | Labels above the part title. |
| parts | `SeriesPart[]` | — | { title, excerpt, readingMinutes, href? } per part. |
| defaultIndex | `number` | 1 | Part shown first. |
| onRead | `(part: SeriesPart, index: number) => void` | — | Called by the Read button. |
| className | `string` | — | Extra classes on the root element. |

### Author-led

Byline-first card with a pull quote, a bookmark that pops and ticks its count, and a copy-link action.

Install: `npx shadcn@latest add @geturui/blog-cards-author`

```tsx
import { AuthorBlogCard } from "@/components/examples/blog-cards/author";

export function Pick() {
  return <AuthorBlogCard author="Tomás Ferreira" quote="People wait for shapes." saves={214} onSavedChange={(s) => save(s)} />;
}
```

| Prop | Type | Default | Description |
|---|---|---|---|
| author / role / monogram | `string` | — | Byline shown first. |
| title / quote | `string` | — | Post title and the pulled line. |
| date / readingMinutes | `string / number` | — | ISO date and reading time. |
| href / shareUrl | `string` | — | Post link and the URL copied by Copy link. |
| saves | `number` | 214 | Bookmark count from other readers. |
| saved / defaultSaved / onSavedChange | `boolean / (saved: boolean) => void` | — | Controlled or initial bookmark state. |
| className | `string` | — | Extra classes on the root element. |

## Accessibility

- Cards are real links or contain one clear link, with a visible focus ring; drawn covers are hidden from assistive tech.
- Toggles such as bookmark use aria-pressed, counts update in place and a status message confirms copied links.
- Parallax, gliding highlights and directional slides fall back to fades with reduced motion.

## Source

### components/examples/blog-cards/editorial.tsx

```tsx
"use client";

import * as React from "react";
import { motion, useMotionValue, useReducedMotion, useSpring, useTransform, type MotionValue } from "motion/react";
import { ArrowUpRight, Clock } from "lucide-react";
import { cn } from "@/lib/utils";

export interface EditorialBlogCardProps {
  category?: string;
  title?: string;
  excerpt?: string;
  author?: string;
  /** Initials for the author avatar; derived from `author` when omitted. */
  monogram?: string;
  /** ISO date (YYYY-MM-DD), shown as "Oct 2, 2026". */
  date?: string;
  readingMinutes?: number;
  /** Short label printed on the cover, e.g. an issue number. */
  issue?: string;
  href?: string;
  onOpen?: () => void;
  className?: string;
}

const initialsOf = (name: string) =>
  name
    .split(/\s+/)
    .map((p) => p[0])
    .filter(Boolean)
    .slice(0, 2)
    .join("")
    .toUpperCase();

const formatPostDate = (iso: string) =>
  new Date(`${iso}T12:00:00Z`).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });

function useDepth(source: MotionValue<number>, depth: number) {
  return useTransform(source, (v) => v * depth);
}

/** Editorial cover card: a drawn type-specimen cover that drifts in layers under the pointer, with category, title, excerpt and byline. */
export function EditorialBlogCard({
  category = "Typography",
  title = "The quiet return of serif type in product interfaces",
  excerpt = "Teams are trading neutral grotesques for warmer serifs in headings. We looked at 40 launches this year to see where it works and where it gets in the way.",
  author = "Iris Calder",
  monogram,
  date = "2026-09-28",
  readingMinutes = 8,
  issue = "Issue 14",
  href = "#",
  onOpen,
  className,
}: EditorialBlogCardProps) {
  const reduce = useReducedMotion() ?? false;
  const px = useMotionValue(0);
  const py = useMotionValue(0);
  const sx = useSpring(px, { stiffness: 150, damping: 20 });
  const sy = useSpring(py, { stiffness: 150, damping: 20 });
  const backX = useDepth(sx, -8);
  const backY = useDepth(sy, -6);
  const midX = useDepth(sx, 10);
  const midY = useDepth(sy, 8);
  const frontX = useDepth(sx, 22);
  const frontY = useDepth(sy, 16);

  function onMove(e: React.PointerEvent<HTMLAnchorElement>) {
    if (reduce || e.pointerType !== "mouse") return;
    const r = e.currentTarget.getBoundingClientRect();
    px.set((e.clientX - r.left) / r.width - 0.5);
    py.set((e.clientY - r.top) / r.height - 0.5);
  }

  function onLeave() {
    px.set(0);
    py.set(0);
  }

  return (
    <motion.a
      href={href}
      onClick={onOpen}
      onPointerMove={onMove}
      onPointerLeave={onLeave}
      whileTap={reduce ? undefined : { scale: 0.98 }}
      transition={{ type: "spring", stiffness: 420, damping: 30 }}
      className={cn(
        "group block w-full max-w-[380px] overflow-hidden rounded-xl border border-border bg-surface-raised shadow-sm outline-none transition-shadow duration-300 hover:shadow-md focus-visible:shadow-[var(--focus-ring)]",
        className,
      )}
    >
      <div aria-hidden className="relative m-2 h-48 overflow-hidden rounded-lg bg-surface-sunken">
        <motion.span style={{ x: backX, y: backY }} className="absolute -right-10 -top-12 size-48 rounded-full border-[18px] border-ink/[0.07]" />
        <motion.div style={{ x: midX, y: midY }} className="absolute bottom-6 left-6 right-24 space-y-1.5">
          {[100, 86, 92, 64].map((w, i) => (
            <span key={i} className="block h-1.5 rounded-full bg-ink/10" style={{ width: `${w}%` }} />
          ))}
        </motion.div>
        <motion.span
          style={{ x: midX, y: midY }}
          className="absolute right-7 top-6 font-display text-[88px] font-semibold leading-none tracking-[-4px] text-ink/85 transition-transform duration-500 ease-spring-soft group-hover:scale-105"
        >
          Aa
        </motion.span>
        <motion.span style={{ x: frontX, y: frontY }} className="absolute bottom-8 right-10 size-11 rounded-full bg-volt shadow-glow" />
        <span className="absolute left-5 top-5 font-mono text-[11px] uppercase tracking-[0.14em] text-ink-subtle">{issue}</span>
      </div>

      <div className="px-5 pb-5 pt-3">
        <div className="flex items-center justify-between gap-3">
          <span className="rounded-full border border-border px-2.5 py-0.5 text-[11.5px] font-medium text-ink-muted">{category}</span>
          <span
            aria-hidden
            className="grid size-8 place-items-center rounded-full border border-border text-ink-muted transition-[translate,color,background-color] duration-300 ease-spring group-hover:-translate-y-0.5 group-hover:translate-x-0.5 group-hover:bg-ink group-hover:text-bg"
          >
            <ArrowUpRight className="size-4" />
          </span>
        </div>
        <h3 className="mt-3 text-balance font-display text-[21px] font-semibold leading-[1.2] tracking-[-0.5px] text-ink">{title}</h3>
        <p className="mt-2 line-clamp-3 text-[13.5px] leading-5 text-ink-muted">{excerpt}</p>

        <div className="mt-4 flex items-center gap-2.5 border-t border-border pt-4">
          <span aria-hidden className="grid size-8 shrink-0 place-items-center rounded-full bg-ink font-display text-[11px] font-semibold text-bg">
            {monogram ?? initialsOf(author)}
          </span>
          <div className="min-w-0 flex-1">
            <p className="truncate text-[13px] font-medium text-ink">{author}</p>
            <p className="text-xs text-ink-subtle">
              <time dateTime={date}>{formatPostDate(date)}</time>
            </p>
          </div>
          <span className="inline-flex items-center gap-1 text-xs text-ink-subtle">
            <Clock aria-hidden className="size-3.5" />
            {readingMinutes} min read
          </span>
        </div>
      </div>
    </motion.a>
  );
}

```

### components/examples/blog-cards/compact-list.tsx

```tsx
"use client";

import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { ArrowRight, Check } from "lucide-react";
import { cn } from "@/lib/utils";

export interface CompactPost {
  id: string;
  title: string;
  category: string;
  /** ISO date (YYYY-MM-DD). */
  date: string;
  readingMinutes: number;
  /** How much the reader has read, 0 to 1. */
  progress?: number;
  href?: string;
}

export interface CompactBlogListProps {
  heading?: string;
  posts?: CompactPost[];
  /** Link for the "View all" action. */
  allHref?: string;
  onSelect?: (post: CompactPost) => void;
  className?: string;
}

const DEFAULT_POSTS: CompactPost[] = [
  { id: "p1", title: "Designing empty states people actually read", category: "Product", date: "2026-09-30", readingMinutes: 6, progress: 1 },
  { id: "p2", title: "A practical guide to spring easing in CSS", category: "Motion", date: "2026-09-26", readingMinutes: 9, progress: 0.45 },
  { id: "p3", title: "What we learned shipping dark mode first", category: "Engineering", date: "2026-09-19", readingMinutes: 7, progress: 0 },
  { id: "p4", title: "Tokens, not themes: naming color for scale", category: "Design systems", date: "2026-09-12", readingMinutes: 11, progress: 0.2 },
];

const shortDate = (iso: string) => new Date(`${iso}T12:00:00Z`).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });

function ProgressRing({ value, reduce }: { value: number; reduce: boolean }) {
  const done = value >= 1;
  return (
    <span className="relative grid size-8 shrink-0 place-items-center">
      <svg viewBox="0 0 32 32" className="absolute inset-0 -rotate-90" aria-hidden>
        <circle cx="16" cy="16" r="13" fill="none" strokeWidth="2.5" className="stroke-ink/10" />
        {value > 0 && (
          <motion.circle
            cx="16"
            cy="16"
            r="13"
            fill="none"
            strokeWidth="2.5"
            strokeLinecap="round"
            className="stroke-volt-ink"
            initial={{ pathLength: reduce ? value : 0 }}
            animate={{ pathLength: value }}
            transition={{ type: "spring", stiffness: 120, damping: 22, delay: 0.15 }}
          />
        )}
      </svg>
      {done ? (
        <Check aria-hidden className="size-3.5 text-volt-ink" strokeWidth={3} />
      ) : value === 0 ? null : (
        <span aria-hidden className="font-mono text-[9px] text-ink-subtle">
          {Math.round(value * 100)}
        </span>
      )}
      <span className="sr-only">{done ? "Finished" : value > 0 ? `${Math.round(value * 100)}% read` : "Not started"}</span>
    </span>
  );
}

/** Compact list of posts with reading-progress rings, a highlight that glides between rows and arrow-key navigation. */
export function CompactBlogList({ heading = "Latest from the journal", posts = DEFAULT_POSTS, allHref = "#", onSelect, className }: CompactBlogListProps) {
  const reduce = useReducedMotion() ?? false;
  const id = React.useId();
  const [active, setActive] = React.useState<number | null>(null);
  const links = React.useRef<(HTMLAnchorElement | null)[]>([]);

  function onKeyDown(e: React.KeyboardEvent<HTMLUListElement>) {
    const current = links.current.findIndex((el) => el === document.activeElement);
    if (current < 0) return;
    const next =
      e.key === "ArrowDown" ? Math.min(posts.length - 1, current + 1) : e.key === "ArrowUp" ? Math.max(0, current - 1) : e.key === "Home" ? 0 : e.key === "End" ? posts.length - 1 : null;
    if (next === null) return;
    e.preventDefault();
    links.current[next]?.focus();
  }

  return (
    <section aria-labelledby={`${id}-h`} className={cn("w-full max-w-[460px] rounded-xl border border-border bg-surface-raised p-2 shadow-sm", className)}>
      <div className="flex items-center justify-between px-3 pb-2 pt-2.5">
        <h3 id={`${id}-h`} className="font-display text-[15px] font-semibold tracking-[-0.2px] text-ink">
          {heading}
        </h3>
        <a
          href={allHref}
          className="group inline-flex items-center gap-1 rounded-xs px-1.5 py-0.5 text-[12.5px] font-medium text-ink-muted outline-none transition-colors hover:text-ink focus-visible:shadow-[var(--focus-ring)]"
        >
          View all
          <ArrowRight aria-hidden className="size-3.5 transition-[translate] duration-200 group-hover:translate-x-0.5" />
        </a>
      </div>
      <ul onKeyDown={onKeyDown} onPointerLeave={() => setActive(null)} className="flex flex-col">
        {posts.map((post, i) => (
          <li key={post.id} className="relative">
            {active === i && (
              <motion.span
                layoutId={reduce ? undefined : `${id}-hl`}
                aria-hidden
                className="absolute inset-0 rounded-lg bg-surface-hover"
                transition={{ type: "spring", stiffness: 420, damping: 34 }}
              />
            )}
            <a
              ref={(el) => {
                links.current[i] = el;
              }}
              href={post.href ?? "#"}
              onClick={() => onSelect?.(post)}
              onPointerEnter={() => setActive(i)}
              onFocus={() => setActive(i)}
              className="relative flex items-center gap-3 rounded-lg px-3 py-3 outline-none focus-visible:shadow-[var(--focus-ring)]"
            >
              <ProgressRing value={post.progress ?? 0} reduce={reduce} />
              <span className="min-w-0 flex-1">
                <span className="line-clamp-2 text-[14px] font-medium leading-5 text-ink">{post.title}</span>
                <span className="mt-0.5 block text-xs text-ink-subtle">
                  {post.category} · <time dateTime={post.date}>{shortDate(post.date)}</time>
                </span>
              </span>
              <span className="shrink-0 rounded-full bg-surface-sunken px-2 py-0.5 font-mono text-[11px] tabular-nums text-ink-muted">{post.readingMinutes} min</span>
            </a>
            {i < posts.length - 1 && <span aria-hidden className="absolute inset-x-3 bottom-0 h-px bg-border" />}
          </li>
        ))}
      </ul>
    </section>
  );
}

```

### components/examples/blog-cards/featured-split.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowRight, ChevronLeft, ChevronRight, Clock } from "lucide-react";
import { cn } from "@/lib/utils";

export interface SeriesPart {
  title: string;
  excerpt: string;
  readingMinutes: number;
  href?: string;
}

export interface FeaturedBlogCardProps {
  /** Small label above the title. */
  eyebrow?: string;
  series?: string;
  parts?: SeriesPart[];
  defaultIndex?: number;
  onRead?: (part: SeriesPart, index: number) => void;
  className?: string;
}

const DEFAULT_PARTS: SeriesPart[] = [
  { title: "Start with the smallest token", excerpt: "Before components, before themes: how a single spacing scale shaped every decision that came after it.", readingMinutes: 7 },
  { title: "Motion that explains itself", excerpt: "Springs, distances and durations that tell people what just happened without asking them to watch.", readingMinutes: 9 },
  { title: "Layers, not lightboxes", excerpt: "Why we replaced most modals with panels that keep context in view, and what broke along the way.", readingMinutes: 8 },
  { title: "Shipping the system to 40 teams", excerpt: "Versioning, codemods and the release notes nobody skipped. The rollout playbook, step by step.", readingMinutes: 12 },
];

function Art({ index }: { index: number }) {
  const k = index % 4;
  return (
    <svg viewBox="0 0 200 160" className="size-full" aria-hidden>
      {k === 0 &&
        Array.from({ length: 20 }, (_, i) => (
          <rect key={i} x={28 + (i % 5) * 30} y={22 + Math.floor(i / 5) * 30} width="22" height="22" rx="6" className={i === 12 ? "fill-volt" : "fill-ink/10"} />
        ))}
      {k === 1 && (
        <g fill="none" strokeWidth="2">
          {[64, 48, 32].map((r) => (
            <circle key={r} cx="100" cy="80" r={r} className="stroke-ink/15" />
          ))}
          <circle cx="148" cy="56" r="9" className="fill-volt stroke-none" />
          <circle cx="100" cy="80" r="6" className="fill-ink/40 stroke-none" />
        </g>
      )}
      {k === 2 &&
        [0, 1, 2].map((i) => (
          <rect key={i} x={46 + i * 18} y={30 + i * 18} width="92" height="70" rx="12" className={i === 2 ? "fill-volt" : i === 1 ? "fill-ink/20" : "fill-ink/10"} />
        ))}
      {k === 3 &&
        Array.from({ length: 6 }, (_, i) => (
          <path
            key={i}
            d={`M10 ${40 + i * 16} C 60 ${20 + i * 16}, 120 ${70 + i * 16}, 190 ${36 + i * 16}`}
            fill="none"
            strokeWidth="3"
            strokeLinecap="round"
            className={i === 3 ? "stroke-volt" : "stroke-ink/15"}
          />
        ))}
    </svg>
  );
}

/** Featured split card for a multi-part series: step through parts with arrows while art and copy crossfade in the direction of travel. */
export function FeaturedBlogCard({
  eyebrow = "Featured series",
  series = "Building a design system in public",
  parts = DEFAULT_PARTS,
  defaultIndex = 1,
  onRead,
  className,
}: FeaturedBlogCardProps) {
  const reduce = useReducedMotion() ?? false;
  const [[index, dir], setState] = React.useState<[number, number]>([Math.min(defaultIndex, parts.length - 1), 0]);
  const part = parts[index];
  const id = React.useId();

  function go(delta: number) {
    const next = index + delta;
    if (next < 0 || next >= parts.length) return;
    setState([next, delta]);
  }

  const offset = reduce ? 0 : 16;
  const swap = {
    initial: (d: number) => ({ opacity: 0, x: d * offset, filter: "blur(8px)" }),
    animate: { opacity: 1, x: 0, filter: "blur(0px)" },
    exit: (d: number) => ({ opacity: 0, x: d * -offset, filter: "blur(8px)" }),
  };
  const btn =
    "grid size-9 place-items-center rounded-full border border-border-strong text-ink outline-none transition-colors hover:bg-surface-hover focus-visible:shadow-[var(--focus-ring)] disabled:opacity-35 disabled:hover:bg-transparent";

  return (
    <section
      aria-labelledby={`${id}-series`}
      onKeyDown={(e) => {
        if (e.key === "ArrowRight") go(1);
        if (e.key === "ArrowLeft") go(-1);
      }}
      className={cn("grid w-full max-w-[680px] overflow-hidden rounded-xl border border-border bg-surface-raised shadow-sm sm:grid-cols-[1fr_1.1fr]", className)}
    >
      <div className="relative m-2 h-48 overflow-hidden rounded-lg bg-surface-sunken sm:h-auto sm:min-h-[260px]">
        <AnimatePresence mode="popLayout" initial={false} custom={dir}>
          <motion.div
            key={index}
            custom={dir}
            variants={swap}
            initial="initial"
            animate="animate"
            exit="exit"
            transition={{ type: "spring", stiffness: 300, damping: 30 }}
            className="absolute inset-0 p-4"
          >
            <Art index={index} />
          </motion.div>
        </AnimatePresence>
        <span className="absolute left-3 top-3 rounded-full bg-glass px-2.5 py-1 font-mono text-[11px] text-ink-muted backdrop-blur">
          Part {index + 1} of {parts.length}
        </span>
      </div>

      <div className="flex flex-col p-5 sm:p-6">
        <p className="text-xs font-medium text-ink-subtle">{eyebrow}</p>
        <p id={`${id}-series`} className="mt-0.5 text-[13px] text-ink-muted">
          {series}
        </p>
        <div aria-live="polite" className="relative mt-4 min-h-[132px]">
          <AnimatePresence mode="wait" initial={false} custom={dir}>
            <motion.div key={index} custom={dir} variants={swap} initial="initial" animate="animate" exit="exit" transition={{ duration: 0.22 }}>
              <h3 className="text-balance font-display text-[22px] font-semibold leading-[1.15] tracking-[-0.5px] text-ink">{part.title}</h3>
              <p className="mt-2 text-[13.5px] leading-5 text-ink-muted">{part.excerpt}</p>
              <p className="mt-3 inline-flex items-center gap-1 text-xs text-ink-subtle">
                <Clock aria-hidden className="size-3.5" />
                {part.readingMinutes} min read
              </p>
            </motion.div>
          </AnimatePresence>
        </div>

        <div aria-hidden className="mt-4 flex gap-1.5">
          {parts.map((_, i) => (
            <span key={i} className="relative h-1 flex-1 overflow-hidden rounded-full bg-ink/10">
              <motion.span
                className="absolute inset-0 origin-left rounded-full bg-ink"
                initial={false}
                animate={{ scaleX: i <= index ? 1 : 0, opacity: i === index ? 1 : 0.35 }}
                transition={{ type: "spring", stiffness: 260, damping: 30 }}
              />
            </span>
          ))}
        </div>

        <div className="mt-5 flex items-center gap-2">
          <motion.a
            href={part.href ?? "#"}
            onClick={() => onRead?.(part, index)}
            whileTap={reduce ? undefined : { scale: 0.96 }}
            className="mr-auto inline-flex h-9 items-center gap-1.5 rounded-full bg-volt px-4 text-[13px] font-medium text-on-volt outline-none transition-shadow hover:shadow-glow focus-visible:shadow-[var(--focus-ring)]"
          >
            Read part {index + 1}
            <ArrowRight aria-hidden className="size-3.5" />
          </motion.a>
          <button type="button" aria-label="Previous part" disabled={index === 0} onClick={() => go(-1)} className={btn}>
            <ChevronLeft aria-hidden className="size-4" />
          </button>
          <button type="button" aria-label="Next part" disabled={index === parts.length - 1} onClick={() => go(1)} className={btn}>
            <ChevronRight aria-hidden className="size-4" />
          </button>
        </div>
      </div>
    </section>
  );
}

```

### components/examples/blog-cards/author.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Bookmark, Check, Link2, Quote } from "lucide-react";
import { cn } from "@/lib/utils";

export interface AuthorBlogCardProps {
  author?: string;
  role?: string;
  /** Initials for the avatar; derived from `author` when omitted. */
  monogram?: string;
  title?: string;
  /** A line pulled from the post, shown large. */
  quote?: string;
  /** ISO date (YYYY-MM-DD). */
  date?: string;
  readingMinutes?: number;
  href?: string;
  /** Absolute URL copied by the share button. */
  shareUrl?: string;
  saves?: number;
  /** Controlled bookmark state. */
  saved?: boolean;
  defaultSaved?: boolean;
  onSavedChange?: (saved: boolean) => void;
  className?: string;
}

const initialsOf = (name: string) =>
  name
    .split(/\s+/)
    .map((p) => p[0])
    .filter(Boolean)
    .slice(0, 2)
    .join("")
    .toUpperCase();

const spring = { type: "spring", stiffness: 420, damping: 30 } as const;

/** Author-led card: byline first, a pull quote from the post, and a bookmark that pops and ticks its count with a copy-link action beside it. */
export function AuthorBlogCard({
  author = "Tomás Ferreira",
  role = "Staff engineer, Payments",
  monogram,
  title = "We deleted half our loading spinners. Conversion went up.",
  quote = "A spinner promises progress it can't show. A skeleton shows the shape of what's coming, and people wait for shapes.",
  date = "2026-09-24",
  readingMinutes = 6,
  href = "#",
  shareUrl = "https://geturui.com/journal/fewer-spinners",
  saves = 214,
  saved,
  defaultSaved = false,
  onSavedChange,
  className,
}: AuthorBlogCardProps) {
  const reduce = useReducedMotion() ?? false;
  const [inner, setInner] = React.useState(defaultSaved);
  const isSaved = saved ?? inner;
  const count = saves + (isSaved ? 1 : 0) - (defaultSaved ? 1 : 0);
  const [copied, setCopied] = React.useState(false);
  const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
  const id = React.useId();

  React.useEffect(() => () => {
    if (timer.current) clearTimeout(timer.current);
  }, []);

  function toggleSave() {
    const next = !isSaved;
    if (saved === undefined) setInner(next);
    onSavedChange?.(next);
  }

  async function copy() {
    try {
      await navigator.clipboard?.writeText(shareUrl);
    } catch {
      // Clipboard can be blocked; the confirmation still shows so the action feels complete.
    }
    setCopied(true);
    if (timer.current) clearTimeout(timer.current);
    timer.current = setTimeout(() => setCopied(false), 1800);
  }

  const date_ = new Date(`${date}T12:00:00Z`).toLocaleDateString("en-US", { month: "long", day: "numeric", timeZone: "UTC" });
  const iconBtn =
    "inline-flex h-9 items-center gap-1.5 rounded-full border border-border-strong px-3 text-[13px] font-medium text-ink outline-none transition-colors hover:bg-surface-hover focus-visible:shadow-[var(--focus-ring)]";

  return (
    <article aria-labelledby={`${id}-t`} className={cn("w-full max-w-[400px] rounded-xl border border-border bg-surface-raised p-5 shadow-sm", className)}>
      <header className="flex items-center gap-3">
        <span aria-hidden className="grid size-11 shrink-0 place-items-center rounded-full bg-ink font-display text-[15px] font-semibold text-bg">
          {monogram ?? initialsOf(author)}
        </span>
        <div className="min-w-0">
          <p className="truncate text-[14px] font-semibold text-ink">{author}</p>
          <p className="truncate text-xs text-ink-subtle">{role}</p>
        </div>
      </header>

      <figure className="relative mt-5 rounded-lg bg-surface-sunken px-5 pb-5 pt-11">
        <Quote aria-hidden className="absolute left-5 top-4 size-5 fill-volt-ink/20 text-volt-ink" />
        <blockquote className="font-display text-[18px] font-medium leading-[1.35] tracking-[-0.3px] text-ink">{quote}</blockquote>
      </figure>

      <h3 id={`${id}-t`} className="mt-4 text-[15px] font-semibold leading-snug text-ink">
        <a href={href} className="rounded-xs outline-none decoration-ink/30 underline-offset-4 hover:underline focus-visible:shadow-[var(--focus-ring)]">
          {title}
        </a>
      </h3>
      <p className="mt-1 text-xs text-ink-subtle">
        <time dateTime={date}>{date_}</time> · {readingMinutes} min read
      </p>

      <div className="mt-4 flex items-center gap-2 border-t border-border pt-4">
        <motion.button
          type="button"
          aria-pressed={isSaved}
          aria-label={isSaved ? "Remove from saved" : "Save for later"}
          onClick={toggleSave}
          whileTap={reduce ? undefined : { scale: 0.94 }}
          transition={spring}
          className={cn(iconBtn, isSaved && "border-transparent bg-ink text-bg hover:bg-ink")}
        >
          <motion.span key={String(isSaved)} initial={reduce || !isSaved ? false : { scale: 0.4, rotate: -20 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: "spring", stiffness: 520, damping: 14 }}>
            <Bookmark aria-hidden className={cn("size-4", isSaved && "fill-current")} />
          </motion.span>
          <span className="relative inline-flex overflow-hidden tabular-nums">
            <AnimatePresence mode="popLayout" initial={false}>
              <motion.span
                key={count}
                initial={reduce ? { opacity: 0 } : { y: isSaved ? 12 : -12, opacity: 0 }}
                animate={{ y: 0, opacity: 1 }}
                exit={reduce ? { opacity: 0 } : { y: isSaved ? -12 : 12, opacity: 0 }}
                transition={spring}
              >
                {count.toLocaleString("en-US")}
              </motion.span>
            </AnimatePresence>
          </span>
        </motion.button>

        <motion.button type="button" onClick={copy} whileTap={reduce ? undefined : { scale: 0.94 }} className={iconBtn}>
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span
              key={copied ? "c" : "l"}
              initial={{ opacity: 0, filter: "blur(6px)", y: reduce ? 0 : 6 }}
              animate={{ opacity: 1, filter: "blur(0px)", y: 0 }}
              exit={{ opacity: 0, filter: "blur(6px)", y: reduce ? 0 : -6 }}
              transition={{ duration: 0.18 }}
              className="inline-flex items-center gap-1.5"
            >
              {copied ? <Check aria-hidden className="size-4 text-volt-ink" strokeWidth={2.5} /> : <Link2 aria-hidden className="size-4" />}
              {copied ? "Copied" : "Copy link"}
            </motion.span>
          </AnimatePresence>
        </motion.button>
        <span role="status" className="sr-only">
          {copied ? "Link copied" : ""}
        </span>
      </div>
    </article>
  );
}

```
