# Blog Home

> An editorial blog home with a featured post, category filters that filter the grid, a popular list, newsletter signup and footer.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/templates/blog-home
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/blog-home.json
```

Adds the page at `app/blog-home/page.tsx` together with the components below. Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Components

- [Newsletter Signup](https://chromotion.dev/r/newsletter-signup.md): `@/components/newsletter-signup`
- [Site Footer](https://chromotion.dev/r/site-footer.md): `@/components/site-footer`

## Dependencies

```bash
npm install lucide-react
```

## Source

```tsx title="app/blog-home/page.tsx"
"use client"

import * as React from "react"
import { ArrowRight, Clock, Hexagon, Mail, Rss, Search, TrendingUp } from "lucide-react"

import { cn } from "@/lib/utils"
import { NewsletterSignup } from "@/components/newsletter-signup"
import { SiteFooter } from "@/components/site-footer"

const categories = ["Engineering", "Design", "Product", "Culture", "Case studies"] as const
type Category = (typeof categories)[number]

interface Post {
  id: string
  title: string
  excerpt: string
  category: Category
  author: string
  date: string
  read: number
  views: number
  cover: string
}

const covers = {
  indigo: "from-indigo-500 via-violet-500 to-fuchsia-500",
  sunset: "from-orange-400 via-rose-500 to-pink-600",
  ocean: "from-sky-400 via-blue-500 to-indigo-600",
  forest: "from-emerald-400 via-teal-500 to-cyan-600",
  amber: "from-amber-300 via-orange-400 to-red-500",
  plum: "from-fuchsia-500 via-purple-600 to-indigo-700",
  slate: "from-slate-500 via-slate-600 to-zinc-800",
}

const featured: Post = {
  id: "featured",
  title: "How we cut build times by 70% without touching a single line of product code",
  excerpt:
    "Our monorepo had grown to 4,000 packages and every pull request waited twelve minutes for CI. Here is the caching strategy, the dead ends, and the metrics that convinced the whole company to adopt it.",
  category: "Engineering",
  author: "Marcus Lee",
  date: "Nov 5, 2026",
  read: 9,
  views: 18240,
  cover: covers.indigo,
}

const posts: Post[] = [
  {
    id: "p1",
    title: "A practical guide to accessible forms",
    excerpt: "Labels, errors, focus order and the small details that decide whether a form works with a keyboard and a screen reader.",
    category: "Design",
    author: "Aiko Tanaka",
    date: "Nov 3, 2026",
    read: 7,
    views: 14980,
    cover: covers.sunset,
  },
  {
    id: "p2",
    title: "Shipping weekly: what changed after we dropped sprints",
    excerpt: "Twelve months after replacing two-week sprints with a weekly release train, we measured what actually improved.",
    category: "Product",
    author: "Priya Raman",
    date: "Nov 1, 2026",
    read: 6,
    views: 9310,
    cover: covers.ocean,
  },
  {
    id: "p3",
    title: "Lessons from running a four-day work week for a year",
    excerpt: "Output stayed flat, burnout dropped, and hiring got easier. The trade-offs we did not expect along the way.",
    category: "Culture",
    author: "Grace Kim",
    date: "Oct 29, 2026",
    read: 8,
    views: 21400,
    cover: covers.forest,
  },
  {
    id: "p4",
    title: "Inside our migration to edge rendering",
    excerpt: "Moving rendering closer to users cut median latency in half. It also exposed every hidden assumption in our data layer.",
    category: "Engineering",
    author: "Rafael Costa",
    date: "Oct 27, 2026",
    read: 11,
    views: 12750,
    cover: covers.slate,
  },
  {
    id: "p5",
    title: "Case study: how Northwind halved onboarding time",
    excerpt: "A guided checklist, a sandbox workspace and one well-timed email took activation from 31% to 58% in a quarter.",
    category: "Case studies",
    author: "Sofia Alvarez",
    date: "Oct 24, 2026",
    read: 5,
    views: 7420,
    cover: covers.amber,
  },
  {
    id: "p6",
    title: "Designing empty states people actually read",
    excerpt: "An empty screen is the most honest moment in your product. Treat it like a conversation, not a placeholder.",
    category: "Design",
    author: "Lars Eriksson",
    date: "Oct 21, 2026",
    read: 4,
    views: 8890,
    cover: covers.plum,
  },
  {
    id: "p7",
    title: "What we learned from 200 customer interviews",
    excerpt: "The interview script, the synthesis board and the three surprises that changed our roadmap for the year.",
    category: "Product",
    author: "Daniel Okafor",
    date: "Oct 18, 2026",
    read: 10,
    views: 11260,
    cover: covers.indigo,
  },
  {
    id: "p8",
    title: "Writing docs that engineers do not skip",
    excerpt: "Start with the task, show the smallest working example, and put the caveats where people will meet them.",
    category: "Engineering",
    author: "Tomás Aguirre",
    date: "Oct 15, 2026",
    read: 6,
    views: 16030,
    cover: covers.ocean,
  },
  {
    id: "p9",
    title: "How a two-person studio ships like a team of twenty",
    excerpt: "Kestrel Studio shares the templates, automation and ruthless scoping that keep a tiny team fast.",
    category: "Case studies",
    author: "Jonas Weber",
    date: "Oct 12, 2026",
    read: 7,
    views: 6580,
    cover: covers.sunset,
  },
  {
    id: "p10",
    title: "Hiring for curiosity: our interview rubric",
    excerpt: "We replaced trivia questions with a structured conversation. Here is the rubric and how it changed who we hire.",
    category: "Culture",
    author: "Hannah Schmidt",
    date: "Oct 9, 2026",
    read: 5,
    views: 10110,
    cover: covers.forest,
  },
]

const popular = [featured, ...posts].sort((a, b) => b.views - a.views).slice(0, 5)

const initials = (name: string) =>
  name
    .split(" ")
    .map((part) => part[0])
    .join("")
    .slice(0, 2)

function Cover({ gradient, className, label }: { gradient: string; className?: string; label?: string }) {
  return (
    <div aria-hidden className={cn("relative overflow-hidden bg-linear-to-br", gradient, className)}>
      <div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_22%,rgba(255,255,255,0.4),transparent_45%),radial-gradient(circle_at_85%_90%,rgba(0,0,0,0.25),transparent_50%)]" />
      <div className="absolute -right-8 -bottom-8 size-40 rounded-full border-[28px] border-white/15" />
      {label && <span className="absolute bottom-3 left-4 text-xs font-medium tracking-wide text-white/80 uppercase">{label}</span>}
    </div>
  )
}

function Byline({ post, className }: { post: Post; className?: string }) {
  return (
    <div className={cn("flex items-center gap-3", className)}>
      <span aria-hidden className="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold">
        {initials(post.author)}
      </span>
      <div className="min-w-0 text-xs">
        <p className="truncate font-medium text-foreground">{post.author}</p>
        <p className="flex flex-wrap items-center gap-x-1.5 text-muted-foreground">
          <time>{post.date}</time>
          <span aria-hidden>·</span>
          <span className="inline-flex items-center gap-1">
            <Clock aria-hidden className="size-3" />
            {post.read} min read
          </span>
        </p>
      </div>
    </div>
  )
}

function Tag({ children }: { children: React.ReactNode }) {
  return <span className="w-fit rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-medium text-primary">{children}</span>
}

function PostCard({ post }: { post: Post }) {
  return (
    <article className="group relative flex min-w-0 flex-col overflow-hidden rounded-2xl border bg-card transition-shadow focus-within:ring-2 focus-within:ring-ring hover:shadow-lg">
      <Cover gradient={post.cover} className="aspect-16/10 transition-transform duration-500 group-hover:scale-[1.03] motion-reduce:transition-none" />
      <div className="flex flex-1 flex-col gap-3 p-5">
        <Tag>{post.category}</Tag>
        <h3 className="text-lg leading-snug font-semibold text-balance">
          <a href="#" className="outline-none after:absolute after:inset-0 group-hover:underline group-hover:underline-offset-4">
            {post.title}
          </a>
        </h3>
        <p className="line-clamp-3 text-sm text-muted-foreground">{post.excerpt}</p>
        <Byline post={post} className="mt-auto pt-2" />
      </div>
    </article>
  )
}

export default function BlogHome() {
  const searchId = React.useId()
  const [category, setCategory] = React.useState<Category | "All">("All")
  const [query, setQuery] = React.useState("")

  const needle = query.trim().toLowerCase()
  const shown = posts.filter(
    (post) =>
      (category === "All" || post.category === category) &&
      (!needle || [post.title, post.excerpt, post.author, post.category].some((text) => text.toLowerCase().includes(needle)))
  )

  return (
    <div className="min-h-svh bg-background text-foreground">
      <header className="sticky top-0 z-30 border-b bg-background/85 backdrop-blur-md">
        <div className="mx-auto flex max-w-6xl flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3 sm:px-6">
          <a href="#" className="flex items-center gap-2 text-lg font-semibold tracking-tight focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none">
            <Hexagon className="size-5 fill-primary text-primary" /> The Hexa Journal
          </a>
          <nav aria-label="Primary" className="ml-4 hidden items-center gap-5 text-sm text-muted-foreground lg:flex">
            {["Stories", "Guides", "Changelog", "About"].map((label, index) => (
              <a key={label} href="#" aria-current={index === 0 ? "page" : undefined} className="transition-colors hover:text-foreground aria-[current=page]:font-medium aria-[current=page]:text-foreground">
                {label}
              </a>
            ))}
          </nav>
          <div className="ml-auto flex items-center gap-2">
            <form role="search" onSubmit={(event) => event.preventDefault()} className="relative hidden w-56 sm:block">
              <label htmlFor={searchId} className="sr-only">
                Search stories
              </label>
              <Search aria-hidden className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
              <input
                id={searchId}
                type="search"
                value={query}
                onChange={(event) => setQuery(event.target.value)}
                placeholder="Search stories"
                autoComplete="off"
                className="h-9 w-full rounded-lg border bg-background pr-3 pl-9 text-sm shadow-xs outline-none transition-[border-color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/20"
              />
            </form>
            <a
              href="#newsletter"
              className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none"
            >
              Subscribe
            </a>
          </div>
          <form role="search" onSubmit={(event) => event.preventDefault()} className="relative w-full sm:hidden">
            <label htmlFor={`${searchId}-m`} className="sr-only">
              Search stories
            </label>
            <Search aria-hidden className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
            <input
              id={`${searchId}-m`}
              type="search"
              value={query}
              onChange={(event) => setQuery(event.target.value)}
              placeholder="Search stories"
              autoComplete="off"
              className="h-9 w-full rounded-lg border bg-background pr-3 pl-9 text-sm shadow-xs outline-none transition-[border-color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/20"
            />
          </form>
        </div>
      </header>

      <main className="mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-12">
        <section aria-labelledby="featured-title">
          <article className="group relative grid overflow-hidden rounded-3xl border bg-card transition-shadow focus-within:ring-2 focus-within:ring-ring hover:shadow-xl md:grid-cols-2">
            <Cover gradient={featured.cover} label="Featured story" className="min-h-56 md:min-h-96" />
            <div className="flex flex-col justify-center gap-4 p-6 sm:p-10">
              <Tag>{featured.category}</Tag>
              <h1 id="featured-title" className="text-2xl leading-tight font-semibold tracking-tight text-balance sm:text-4xl">
                <a href="#" className="outline-none after:absolute after:inset-0">
                  {featured.title}
                </a>
              </h1>
              <p className="text-pretty text-muted-foreground">{featured.excerpt}</p>
              <Byline post={featured} />
              <p className="inline-flex items-center gap-1.5 text-sm font-medium text-primary">
                Read the story <ArrowRight aria-hidden className="size-4 transition-transform group-hover:translate-x-1 motion-reduce:transition-none" />
              </p>
            </div>
          </article>
        </section>

        <div className="mt-14 grid gap-10 lg:grid-cols-[minmax(0,1fr)_300px]">
          <section aria-labelledby="latest-title" className="min-w-0">
            <div className="flex flex-col gap-4">
              <h2 id="latest-title" className="text-2xl font-semibold tracking-tight">
                Latest stories
              </h2>
              <div role="group" aria-label="Filter by category" className="-mx-4 flex gap-2 overflow-x-auto px-4 pb-1 sm:mx-0 sm:flex-wrap sm:px-0">
                {(["All", ...categories] as const).map((name) => (
                  <button
                    key={name}
                    type="button"
                    aria-pressed={category === name}
                    onClick={() => setCategory(name)}
                    className="h-9 shrink-0 rounded-full border bg-background px-4 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground"
                  >
                    {name}
                  </button>
                ))}
              </div>
              <p role="status" className="text-sm text-muted-foreground">
                Showing {shown.length} {shown.length === 1 ? "story" : "stories"}
                {category !== "All" ? ` in ${category}` : ""}
                {needle ? ` matching “${query.trim()}”` : ""}
              </p>
            </div>

            {shown.length === 0 ? (
              <div className="mt-6 flex flex-col items-center gap-3 rounded-2xl border border-dashed px-6 py-16 text-center">
                <Search aria-hidden className="size-8 text-muted-foreground/60" />
                <p className="font-medium">No stories found</p>
                <p className="text-sm text-muted-foreground">Try another category or a different search term.</p>
                <button
                  type="button"
                  onClick={() => {
                    setCategory("All")
                    setQuery("")
                  }}
                  className="mt-1 h-9 rounded-lg border bg-background px-4 text-sm font-medium hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
                >
                  Clear filters
                </button>
              </div>
            ) : (
              <div className="mt-6 grid gap-6 sm:grid-cols-2">
                {shown.map((post) => (
                  <PostCard key={post.id} post={post} />
                ))}
              </div>
            )}
          </section>

          <aside aria-labelledby="popular-title" className="min-w-0">
            <div className="rounded-2xl border bg-card p-5 lg:sticky lg:top-24">
              <h2 id="popular-title" className="flex items-center gap-2 text-lg font-semibold">
                <TrendingUp aria-hidden className="size-4 text-primary" /> Popular
              </h2>
              <ol className="mt-4 flex flex-col divide-y">
                {popular.map((post, index) => (
                  <li key={post.id} className="flex gap-4 py-3 first:pt-0 last:pb-0">
                    <span aria-hidden className="w-7 shrink-0 text-2xl leading-none font-semibold text-muted-foreground/70 tabular-nums">
                      {String(index + 1).padStart(2, "0")}
                    </span>
                    <div className="min-w-0">
                      <a href="#" className="text-sm leading-snug font-medium text-pretty hover:underline hover:underline-offset-4 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none">
                        {post.title}
                      </a>
                      <p className="mt-1 text-xs text-muted-foreground">
                        {post.author} · {post.read} min read
                      </p>
                    </div>
                  </li>
                ))}
              </ol>
            </div>
          </aside>
        </div>

        <section
          id="newsletter"
          aria-labelledby="newsletter-title"
          className="relative mt-16 scroll-mt-24 overflow-hidden rounded-3xl border bg-linear-to-br from-primary/10 via-card to-card px-6 py-12 text-center sm:px-12"
        >
          <div className="mx-auto flex max-w-xl flex-col items-center gap-4">
            <span aria-hidden className="flex size-12 items-center justify-center rounded-full bg-primary/10 text-primary">
              <Mail className="size-5" />
            </span>
            <h2 id="newsletter-title" className="text-2xl font-semibold tracking-tight text-balance sm:text-3xl">
              One thoughtful email, every Friday
            </h2>
            <p className="text-pretty text-muted-foreground">
              Join 18,000 engineers and designers who read our best stories, tools and links before the weekend.
            </p>
            <NewsletterSignup
              layout="stacked"
              buttonLabel="Subscribe"
              placeholder="you@company.com"
              hint="No spam. Unsubscribe with one click."
              successMessage="You're in. Check your inbox to confirm your subscription."
              onSubscribe={() => new Promise<void>((resolve) => setTimeout(resolve, 700))}
              className="text-left"
            />
          </div>
        </section>
      </main>

      <SiteFooter
        brand={
          <span className="flex items-center gap-2">
            <Hexagon className="size-5 fill-primary text-primary" /> The Hexa Journal
          </span>
        }
        tagline="Stories on engineering, design and building products people love."
        columns={[
          { title: "Read", links: [{ label: "Latest", href: "#" }, { label: "Guides", href: "#" }, { label: "Case studies", href: "#" }] },
          { title: "Topics", links: [{ label: "Engineering", href: "#" }, { label: "Design", href: "#" }, { label: "Product", href: "#" }] },
          { title: "Company", links: [{ label: "About", href: "#" }, { label: "Careers", href: "#" }, { label: "Contact", href: "#" }] },
          { title: "Follow", links: [{ label: "RSS feed", href: "#" }, { label: "Newsletter", href: "#newsletter" }] },
        ]}
        socials={[
          { label: "RSS", href: "#", icon: Rss },
          { label: "Email", href: "#", icon: Mail },
        ]}
        legal="© 2026 Hexa Labs, Inc. All rights reserved."
        bottomLinks={[{ label: "Privacy", href: "#" }, { label: "Terms", href: "#" }]}
        wordmark="JOURNAL"
      />
    </div>
  )
}
```
