Blog 1

Editorial blog index with a featured post on top and a 4-column grid of post cards underneath. Built on Card and Badge.

Preview

Installation

npx shadcn@latest add https://hirael.com/r/blog-01.json

Code

components/blocks/blog-01.tsx
"use client";

import * as React from "react";
import Image from "next/image";
import { ArrowRight, Clock } from "lucide-react";

import { Badge } from "@/registry/hirael/ui/badge";
import { Button } from "@/registry/hirael/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/registry/hirael/ui/card";
import { Separator } from "@/registry/hirael/ui/separator";

type Post = {
  category: string;
  title: string;
  excerpt: string;
  author: { name: string; initials: string };
  date: string;
  readMin: number;
  href: string;
  /** Optional cover image URL. If absent, a stylized placeholder renders. */
  cover?: string;
};

// Free-to-use photos from Unsplash. Swap for your own assets — and remember
// to add the image host to `images.remotePatterns` in next.config.
const IMG = {
  a: "https://images.unsplash.com/photo-1502447533750-9860c1269ae3?q=80&w=687&auto=format&fit=crop",
  b: "https://images.unsplash.com/photo-1571832194445-4480b617ee01?q=80&w=880&auto=format&fit=crop",
  c: "https://images.unsplash.com/photo-1678769435037-2de940e13c89?q=80&w=750&auto=format&fit=crop",
} as const;

const FEATURED: Post = {
  category: "Engineering",
  title: "Why we shipped the same component twice, and why you should too.",
  excerpt:
    "Every Hirael primitive exposes a compound surface and a single-prop surface in the same file. Here's the design contract behind it, and the four bugs it quietly prevented in production.",
  author: { name: "John Doe", initials: "JD" },
  date: "May 22 · 2026",
  readMin: 9,
  href: "#",
  cover: IMG.a,
};

const POSTS: readonly Post[] = [
  {
    category: "Patterns",
    title: "Async combobox without the race conditions.",
    excerpt:
      "A debounced loader, a stable request id, and an abort signal walk into a useEffect…",
    author: { name: "Maya Renner", initials: "MR" },
    date: "May 14",
    readMin: 5,
    href: "#",
    cover: IMG.b,
  },
  {
    category: "Design",
    title: "The 1px border, and other invisible decisions.",
    excerpt:
      "How a single token decision propagates through every surface in the registry, and why we picked 0.65rem.",
    author: { name: "Jules Tanaka", initials: "JT" },
    date: "May 06",
    readMin: 4,
    href: "#",
    cover: IMG.c,
  },
  {
    category: "Release",
    title: "v1.3 · year picker, eyedropper, dense data tables.",
    excerpt:
      "Three new primitives, a quiet API revision to combobox, and a long-deferred fix for SSR hydration in tag-input.",
    author: { name: "Adaeze Okafor", initials: "AO" },
    date: "Apr 29",
    readMin: 3,
    href: "#",
    cover: IMG.a,
  },
  {
    category: "Field notes",
    title: "Shipping for design systems teams, by design systems teams.",
    excerpt:
      "What we learned watching three platform teams swap our components into their codebase over a sprint.",
    author: { name: "Soren Kim", initials: "SK" },
    date: "Apr 18",
    readMin: 7,
    href: "#",
    cover: IMG.b,
  },
];

function PostCover({
  cover,
  alt,
  category,
  featured,
}: {
  cover?: string;
  alt: string;
  category: string;
  featured?: boolean;
}) {
  if (cover) {
    return (
      <div className="relative size-full overflow-hidden">
        <Image
          src={cover}
          alt={alt}
          fill
          sizes="(min-width: 1024px) 50vw, (min-width: 640px) 50vw, 100vw"
          className="object-cover transition-transform duration-500 ease-out group-hover:scale-[1.02]"
        />
        <div className="absolute inset-x-0 bottom-0 flex items-end gap-2 bg-gradient-to-t from-black/60 via-black/10 to-transparent p-4">
          {featured && <Badge>Featured</Badge>}
          <Badge variant="outline" className="border-white/30 text-white">
            {category}
          </Badge>
        </div>
      </div>
    );
  }

  return (
    <div className="relative size-full overflow-hidden bg-card">
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 opacity-40 [mask-image:radial-gradient(ellipse_at_center,black_30%,transparent_75%)]"
        style={{
          backgroundImage:
            "linear-gradient(to right, var(--border) 1px, transparent 1px), linear-gradient(to bottom, var(--border) 1px, transparent 1px)",
          backgroundSize: "24px 24px",
        }}
      />
      <div
        aria-hidden
        className="pointer-events-none absolute -end-16 -top-16 size-64 rounded-full opacity-[0.18] blur-3xl"
        style={{ background: "var(--primary)" }}
      />
      <div className="absolute inset-0 flex items-end p-4">
        <div className="flex items-center gap-2">
          {featured && <Badge>Featured</Badge>}
          <Badge variant="outline">{category}</Badge>
        </div>
      </div>
    </div>
  );
}

export default function Blog01() {
  return (
    <section className="bg-background py-20 sm:py-28">
      <div className="container w-full">
        <div className="flex flex-col gap-5 border-b border-border pb-10 sm:flex-row sm:items-end sm:justify-between">
          <div className="flex max-w-xl flex-col gap-4">
            <Badge variant="outline" className="w-fit">
              journal
            </Badge>
            <h2 className="font-serif text-4xl font-medium leading-[1.04] tracking-tight sm:text-5xl">
              Writing from the workshop.
            </h2>
            <p className="text-base text-muted-foreground">
              Patterns, release notes, and field reports from teams putting
              Hirael to work. No launch tweets, no growth posts.
            </p>
          </div>
          <Button variant="link" className="group h-auto p-0" asChild>
            <a href="#">
              All posts
              <ArrowRight className="size-3.5 transition-transform duration-150 ease-out group-hover:translate-x-0.5 rtl:rotate-180 rtl:group-hover:-translate-x-0.5" />
            </a>
          </Button>
        </div>

        <Card className="group mt-10 gap-0 overflow-hidden p-0 transition-colors hover:border-foreground/30 focus-within:border-foreground/30">
          <article
            aria-labelledby="blog-01-featured-title"
            className="relative grid grid-cols-1 lg:grid-cols-12"
          >
            <a
              href={FEATURED.href}
              aria-hidden
              tabIndex={-1}
              className="block aspect-[16/10] lg:col-span-7 lg:aspect-auto"
            >
              <PostCover
                cover={FEATURED.cover}
                alt={FEATURED.title}
                category={FEATURED.category}
                featured
              />
            </a>

            <div className="flex flex-col gap-4 p-6 lg:col-span-5 lg:p-8">
              <span className="font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">
                {FEATURED.date} · {FEATURED.readMin} min read
              </span>
              <h3
                id="blog-01-featured-title"
                className="text-2xl font-semibold leading-[1.15] tracking-[-0.025em] sm:text-3xl"
              >
                <a
                  href={FEATURED.href}
                  className="after:absolute after:inset-0 focus-visible:outline-none"
                >
                  {FEATURED.title}
                </a>
              </h3>
              <p className="text-sm leading-relaxed text-muted-foreground sm:text-base">
                {FEATURED.excerpt}
              </p>
              <div className="mt-2 flex items-center gap-3">
                <span className="inline-flex size-8 items-center justify-center rounded-full border border-border bg-muted font-mono text-xs font-medium text-foreground">
                  {FEATURED.author.initials}
                </span>
                <span className="text-sm text-foreground">
                  {FEATURED.author.name}
                </span>
              </div>
              <Button
                asChild
                variant="default"
                className="group/cta relative z-10 mt-2 w-fit"
              >
                <a href={FEATURED.href}>
                  Read the post
                  <ArrowRight className="size-4 transition-transform duration-150 ease-out group-hover/cta:translate-x-0.5 rtl:rotate-180 rtl:group-hover/cta:-translate-x-0.5" />
                </a>
              </Button>
            </div>
          </article>
        </Card>

        <div className="mt-10 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
          {POSTS.map((p) => {
            const titleId = `blog-01-post-${p.title.replace(/\s+/g, "-").slice(0, 24)}`;
            return (
              <Card
                key={p.title}
                className="group relative gap-0 overflow-hidden p-0 transition-colors hover:border-foreground/30 focus-within:border-foreground/30"
              >
                <article aria-labelledby={titleId} className="contents">
                  {p.cover && (
                    <a
                      href={p.href}
                      aria-hidden
                      tabIndex={-1}
                      className="block aspect-[16/10] overflow-hidden"
                    >
                      <PostCover
                        cover={p.cover}
                        alt={p.title}
                        category={p.category}
                      />
                    </a>
                  )}
                  <CardHeader className="px-5 pt-5">
                    <div className="flex items-center justify-between">
                      {!p.cover ? (
                        <Badge variant="outline">{p.category}</Badge>
                      ) : (
                        <span aria-hidden />
                      )}
                      <span className="inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-[0.1em] text-muted-foreground">
                        <Clock className="size-2.5" />
                        {p.readMin}m
                      </span>
                    </div>
                    <CardTitle
                      id={titleId}
                      className="mt-2 text-base leading-snug tracking-[-0.01em]"
                    >
                      <a
                        href={p.href}
                        className="after:absolute after:inset-0 focus-visible:outline-none"
                      >
                        {p.title}
                      </a>
                    </CardTitle>
                  </CardHeader>
                  <CardContent className="px-5 py-4">
                    <CardDescription className="line-clamp-3">
                      {p.excerpt}
                    </CardDescription>
                  </CardContent>
                  <Separator />
                  <CardFooter className="px-5 py-4">
                    <div className="flex w-full items-center justify-between gap-3">
                      <div className="flex items-center gap-2">
                        <span className="inline-flex size-6 items-center justify-center rounded-full border border-border bg-muted font-mono text-[10px] font-medium text-foreground">
                          {p.author.initials}
                        </span>
                        <span className="truncate text-xs text-foreground">
                          {p.author.name}
                        </span>
                      </div>
                      <span className="font-mono text-[10px] uppercase tracking-[0.1em] text-muted-foreground">
                        {p.date}
                      </span>
                    </div>
                  </CardFooter>
                </article>
              </Card>
            );
          })}
        </div>
      </div>
    </section>
  );
}

Dependencies

shadcn registry

badgebuttoncardseparator

npm

lucide-react