# Blog 1: featured post with filterable grid

> A blog index with a large featured post, topic filter chips and a three-column grid. Posts without a photo get a generated cover drawn from theme tokens, in five compositions.

A shadcn/ui block from the [@ballmac](https://ui.ballmac.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/ballmac/blog-1).

- Page, with a live preview: https://shadcn.exchange/r/ballmac/blog-1
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/ballmac/blog-1-light-bf57bef555d4ca82.png
- Registry item: https://ui.ballmac.com/r/blog-1.json
- Categories: blocks, blog
- Listed: 2026-10-14, source last changed 2026-10-14

## Install

```bash
npx shadcn@latest add https://ui.ballmac.com/r/blog-1.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `utils`, `https://ui.ballmac.com/r/i18n.json`, `https://ui.ballmac.com/r/media.json`, `https://ui.ballmac.com/r/state-view.json`, `https://ui.ballmac.com/r/skeleton.json`

## Files

### `registry/ballmac/components/blocks/blog-1/blog-1.tsx`

```tsx
// Ballmac UI: Blog 1. https://ui.ballmac.com/blocks/blog-1
"use client"

import * as React from "react"
import { ArrowUpRight, Clock } from "lucide-react"

import { Skeleton } from "@/components/ballmac/skeleton"
import { frameProps, StateView, type StateProps } from "@/components/ballmac/state-view"
import { cn } from "@/lib/utils"
import { useLocale } from "@/lib/ballmac/i18n"
import { Media, type MediaSource } from "@/components/ballmac/media"

type Blog1Post = {
  title: string
  /** One or two sentences under the title. */
  excerpt: string
  /** Topic, used for the filter chips. */
  category: string
  /** Publication date as an ISO string (YYYY-MM-DD). Shown in UTC so server and browser agree. */
  date: string
  /** Estimated minutes to read. */
  readMinutes: number
  /** Author's name. */
  author: string
  /** Author's role. */
  role?: string
  /** Where the post lives. */
  href: string
  /** Cover photo: an image URL, an object with alt text and a dark-mode file, or your own element. A generated cover is drawn when omitted. */
  image?: MediaSource
  /** Describes `image` when it is a plain URL. The title sits beside the cover, so the default is decorative (empty). */
  imageAlt?: string
  /** Which generated cover to draw (0 to 4). Defaults to the post's position. */
  cover?: number
}

type Blog1Props = Omit<React.ComponentProps<"section">, "title"> & StateProps & {
  /** Label above the heading. */
  eyebrow?: string
  /** Section heading. */
  title?: string
  /** One sentence under the heading. */
  description?: string
  /** Posts, newest first. The first one is featured. */
  posts?: Blog1Post[]
  /** Show category filter chips. */
  filterable?: boolean
  /** Locale for dates. Fixed by default so server and browser match. */
  locale?: string
}

const tones = [
  { bg: "from-chart-1/30 to-chart-1/5", solid: "bg-chart-1", soft: "bg-chart-1/25", text: "text-chart-1", stroke: "stroke-chart-1" },
  { bg: "from-chart-3/30 to-chart-3/5", solid: "bg-chart-3", soft: "bg-chart-3/25", text: "text-chart-3", stroke: "stroke-chart-3" },
  { bg: "from-chart-5/30 to-chart-5/5", solid: "bg-chart-5", soft: "bg-chart-5/25", text: "text-chart-5", stroke: "stroke-chart-5" },
  { bg: "from-chart-2/30 to-chart-2/5", solid: "bg-chart-2", soft: "bg-chart-2/25", text: "text-chart-2", stroke: "stroke-chart-2" },
  { bg: "from-chart-4/30 to-chart-4/5", solid: "bg-chart-4", soft: "bg-chart-4/25", text: "text-chart-4", stroke: "stroke-chart-4" },
]

type BlogCoverProps = React.ComponentProps<"div"> & {
  /** Which generated cover to draw. Wraps after the fifth. */
  variant?: number
}

/** A decorative generated cover drawn from theme tokens: five compositions, each in a different chart colour. */
function BlogCover({ variant = 0, className, ...props }: BlogCoverProps) {
  const i = ((variant % 5) + 5) % 5
  const t = tones[i]
  return (
    <div
      aria-hidden="true"
      data-slot="blog-cover"
      className={cn("relative isolate overflow-hidden bg-gradient-to-br", t.bg, className)}
      {...props}
    >
      {i === 0 && (
        <>
          {[88, 64, 40, 18].map((s, k) => (
            <span key={s} className={cn("absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full border", t.soft, k === 3 && t.solid, "border-foreground/5")} style={{ width: `${s}%`, aspectRatio: "1" }} />
          ))}
        </>
      )}
      {i === 1 && (
        <div className="absolute inset-0 flex -skew-x-12 items-end justify-center gap-[4%] px-[12%]">
          {[44, 68, 54, 82, 62, 94].map((h, k) => (
            <span key={k} className={cn("w-[10%] rounded-t-xl", k % 2 ? t.solid : t.soft)} style={{ height: `${h}%` }} />
          ))}
        </div>
      )}
      {i === 2 && (
        <>
          <div className="absolute inset-0 bg-[radial-gradient(color-mix(in_oklch,var(--foreground)_20%,transparent)_1px,transparent_1px)] [background-size:16px_16px]" />
          <span className={cn("absolute top-[18%] start-[16%] size-[46%] rotate-12 rounded-[22%]", t.solid)} />
          <span className={cn("absolute end-[14%] bottom-[16%] size-[34%] -rotate-6 rounded-[28%] border border-foreground/10", t.soft)} />
        </>
      )}
      {i === 3 && (
        <>
          <span className={cn("absolute -top-[20%] -start-[10%] size-[70%] rounded-full blur-2xl", t.soft)} />
          <span className={cn("absolute top-[22%] end-[8%] size-[56%] rounded-[45%_55%_60%_40%]", t.solid)} />
          <span className="bg-background/60 absolute bottom-[12%] start-[18%] h-[22%] w-[44%] rounded-full backdrop-blur-sm" />
        </>
      )}
      {i === 4 && (
        <svg viewBox="0 0 400 250" preserveAspectRatio="xMidYMid slice" className="absolute inset-0 size-full">
          {[0, 1, 2, 3, 4, 5, 6].map((k) => (
            <path key={k} d={`M-20 ${60 + k * 22} C 80 ${10 + k * 24}, 140 ${150 + k * 12}, 230 ${90 + k * 18} S 360 ${30 + k * 20}, 430 ${100 + k * 16}`} fill="none" strokeWidth={k === 3 ? 3 : 1.5} className={cn(t.stroke, k === 3 ? "opacity-100" : "opacity-40")} />
          ))}
        </svg>
      )}
    </div>
  )
}

const defaults: Blog1Post[] = [
  { title: "How we cut month-end close from five days to one", excerpt: "A look at the three workflow changes behind our own fastest close yet, and the dashboards that made them stick.", category: "Product", date: "2026-09-24", readMinutes: 8, author: "Priya Raman", role: "CEO", href: "#" },
  { title: "Designing calm software for busy teams", excerpt: "Notes on restraint: fewer colours, quieter motion and why every empty state is a chance to help.", category: "Design", date: "2026-09-17", readMinutes: 6, author: "Elena Fischer", role: "Head of Design", href: "#" },
  { title: "Inside our new audit log", excerpt: "Append-only storage, signed exports and a query language your auditors can read.", category: "Engineering", date: "2026-09-09", readMinutes: 11, author: "Amara Singh", role: "Staff Engineer", href: "#" },
  { title: "What 2,000 invoices taught us about getting paid", excerpt: "The wording, timing and layout choices that move the median payout from 14 days to 5.", category: "Research", date: "2026-08-28", readMinutes: 7, author: "Jonas Ortiz", role: "Product Manager", href: "#" },
  { title: "Announcing SAML SSO and SCIM", excerpt: "Enterprise sign-in is here for Pro and Scale, with setup guides for Okta, Entra ID and Google.", category: "Product", date: "2026-08-19", readMinutes: 4, author: "Marcus Webb", role: "CTO", href: "#" },
  { title: "A field guide to resilient webhooks", excerpt: "Retries, idempotency keys and replay tools: the boring details that keep integrations alive.", category: "Engineering", date: "2026-08-05", readMinutes: 9, author: "Amara Singh", role: "Staff Engineer", href: "#" },
  { title: "Customer story: how Northwind went paperless", excerpt: "Forty suppliers, one inbox and no more lost PDFs. A conversation with their finance lead.", category: "Customers", date: "2026-07-30", readMinutes: 5, author: "Tomás Herrera", role: "Customer Success", href: "#" },
]

function formatDate(iso: string, locale: string) {
  return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeZone: "UTC" }).format(new Date(`${iso}T00:00:00Z`))
}

function Meta({ post, locale, className }: { post: Blog1Post; locale: string; className?: string }) {
  return (
    <p className={cn("text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-1 text-sm", className)}>
      <time dateTime={post.date}>{formatDate(post.date, locale)}</time>
      <span aria-hidden="true">·</span>
      <span className="inline-flex items-center gap-1">
        <Clock className="size-3.5" aria-hidden="true" />
        {post.readMinutes} min read
      </span>
    </p>
  )
}

function Cover({ post, index, className }: { post: Blog1Post; index: number; className?: string }) {
  if (post.image) return <Media media={post.image} alt={post.imageAlt ?? ""} fill className={className} />
  return <BlogCover variant={post.cover ?? index} className={className} />
}

function Blog1Inner({
  eyebrow = "The Acme blog",
  title = "Ideas, updates and field notes.",
  description = "What we are building, how we think about it and what we learn from the teams who use it.",
  posts = defaults,
  filterable = true,
  locale,
  className,
  ...props
}: Blog1Props) {
  const defaultLocale = useLocale()
  locale ??= defaultLocale
  const [category, setCategory] = React.useState("All")
  const categories = ["All", ...Array.from(new Set(posts.map((p) => p.category)))]
  const shown = posts.filter((p) => category === "All" || p.category === category)
  const [featured, ...rest] = shown

  return (
    <section data-slot="blog-1" className={cn("mx-auto max-w-6xl px-4 py-20 sm:px-6 md:py-28", className)} {...props}>
      <div className="max-w-2xl">
        <p className="text-muted-foreground text-sm font-medium">{eyebrow}</p>
        <h2 className="mt-3 text-3xl font-semibold tracking-[-0.035em] text-balance sm:text-4xl lg:text-5xl">{title}</h2>
        <p className="text-muted-foreground mt-4 text-lg text-pretty">{description}</p>
      </div>

      {filterable && categories.length > 2 && (
        <div role="group" aria-label="Filter by topic" className="-mx-4 mt-8 flex gap-1.5 overflow-x-auto px-4 pb-1 [scrollbar-width:none] sm:mx-0 sm:px-0">
          {categories.map((c) => (
            <button
              key={c}
              type="button"
              aria-pressed={category === c}
              onClick={() => setCategory(c)}
              className={cn(
                "focus-visible:ring-ring/50 h-9 shrink-0 rounded-full border px-4 text-sm font-medium outline-none transition-colors focus-visible:ring-[3px]",
                category === c ? "bg-foreground text-background border-transparent" : "text-muted-foreground hover:text-foreground hover:bg-accent"
              )}
            >
              {c}
            </button>
          ))}
        </div>
      )}

      <p role="status" className="sr-only">{shown.length} {shown.length === 1 ? "post" : "posts"} shown.</p>

      {featured && (
        <article className="group/post bg-card relative mt-10 grid overflow-hidden rounded-3xl border transition-shadow duration-300 hover:shadow-[0_30px_70px_-40px_rgb(0_0_0/0.45)] md:grid-cols-[1.15fr_1fr]">
          <Cover post={featured} index={posts.indexOf(featured)} className="aspect-[16/10] w-full transition-transform duration-700 ease-out group-hover/post:scale-[1.03] md:aspect-auto md:h-full motion-reduce:transition-none motion-reduce:group-hover/post:scale-100" />
          <div className="bg-card relative flex flex-col justify-center p-6 sm:p-10 lg:p-12">
            <span className="bg-secondary w-fit rounded-full px-2.5 py-1 text-xs font-medium">{featured.category}</span>
            <h3 className="mt-4 text-2xl font-semibold tracking-[-0.03em] text-balance sm:text-3xl">
              <a href={featured.href} className="focus-visible:ring-ring/50 rounded-sm outline-none after:absolute after:inset-0 focus-visible:ring-[3px]">{featured.title}</a>
            </h3>
            <p className="text-muted-foreground mt-3 text-pretty">{featured.excerpt}</p>
            <Meta post={featured} locale={locale} className="mt-6" />
            <p className="mt-4 text-sm">
              <span className="font-medium">{featured.author}</span>
              {featured.role && <span className="text-muted-foreground">, {featured.role}</span>}
            </p>
          </div>
        </article>
      )}

      {rest.length > 0 && (
        <ul className="mt-8 grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
          {rest.map((post) => (
            <li key={post.title}>
              <article className="group/post relative flex h-full flex-col">
                <div className="overflow-hidden rounded-2xl border">
                  <Cover post={post} index={posts.indexOf(post)} className="aspect-[16/10] w-full transition-transform duration-700 ease-out group-hover/post:scale-[1.04] motion-reduce:transition-none motion-reduce:group-hover/post:scale-100" />
                </div>
                <div className="mt-4 flex flex-1 flex-col">
                  <p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">{post.category}</p>
                  <h3 className="mt-2 text-lg leading-snug font-semibold tracking-[-0.02em] text-balance">
                    <a href={post.href} className="focus-visible:ring-ring/50 rounded-sm outline-none after:absolute after:inset-0 focus-visible:ring-[3px]">
                      {post.title}
                      <ArrowUpRight className="ms-1 inline size-4 -translate-x-1 opacity-0 transition-all group-hover/post:translate-x-0 group-hover/post:opacity-100 motion-reduce:transition-none rtl:-scale-x-100" aria-hidden="true" />
                    </a>
                  </h3>
                  <p className="text-muted-foreground mt-2 line-clamp-2 text-sm text-pretty">{post.excerpt}</p>
                  <Meta post={post} locale={locale} className="mt-auto pt-4" />
                </div>
              </article>
            </li>
          ))}
        </ul>
      )}
    </section>
  )
}


/** The same shapes as the ready view, so nothing jumps when data arrives. */
function Blog1Skeleton() {
  return (
    <div aria-hidden="true">
      <div className="bg-card grid overflow-hidden rounded-3xl border lg:grid-cols-2">
        <Skeleton className="aspect-[16/10] w-full rounded-none" />
        <div className="flex flex-col justify-center gap-4 p-6 sm:p-10 lg:p-12">
          <Skeleton className="h-6 w-24 rounded-full" />
          <Skeleton className="h-8 w-4/5" />
          <Skeleton className="h-4 w-full" />
          <Skeleton className="h-4 w-3/4" />
          <Skeleton className="mt-4 h-8 w-48" />
        </div>
      </div>
      <div className="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
        {[0, 1, 2].map((i) => (
          <div key={i} className="grid gap-3">
            <Skeleton className="aspect-[16/10] w-full rounded-2xl" />
            <Skeleton className="h-5 w-20 rounded-full" />
            <Skeleton className="h-6 w-5/6" />
            <Skeleton className="h-4 w-full" />
            <Skeleton className="h-4 w-2/3" />
          </div>
        ))}
      </div>
    </div>
  )
}

/**
 * With `state="loading"` this shows a skeleton of its layout under the real heading, with `state="error"` an error with retry, and with `state="empty"` a message that there is nothing to show yet.
 */
function Blog1({ state = "ready", onRetry, emptyState, errorState, eyebrow = "The Acme blog", title = "Ideas, updates and field notes.", description = "What we are building, how we think about it and what we learn from the teams who use it.", className, ...props }: Blog1Props) {
  if (state === "ready") return <Blog1Inner eyebrow={eyebrow} title={title} description={description} className={className} {...props} />
  return (
    <section data-slot="blog-1" data-state={state} className={cn("mx-auto max-w-6xl px-4 py-20 sm:px-6 md:py-28", className)} {...frameProps(props)}>
      <div className="max-w-2xl">
        <p className="text-muted-foreground text-sm font-medium">{eyebrow}</p>
        <h2 className="mt-3 text-3xl font-semibold tracking-[-0.035em] text-balance sm:text-4xl lg:text-5xl">{title}</h2>
        <p className="text-muted-foreground mt-4 text-lg text-pretty">{description}</p>
      </div>
      <StateView
        state={state}
        className="mt-10"
        loading={<Blog1Skeleton />}
        label="Loading posts"
        empty={{ title: 'No posts yet', description: 'New articles will appear here as soon as they are published.', ...emptyState }}
        error={{ title: 'Could not load the posts', description: 'The blog did not answer. Please try again.', ...errorState }}
        onRetry={onRetry}
      >
        {null}
      </StateView>
    </section>
  )
}

export { Blog1, BlogCover, type Blog1Props, type Blog1Post, type BlogCoverProps }
```
