Editorial

Post

Blog post listing parts: a Grid list or divided column of posts, each made fully clickable by its title link, with cover media, dotted meta for date, category and author, title and clamped excerpt.

Free, MIT licenseOpen in Browse
import type { ComponentProps } from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"

import { Grid, type GridProps } from "@/components/editorial/grid"

// PostGroup is a Grid rendered as a list: Grid's wrapper decides the columns.
// The list itself stays the post-group container, so row posts and lg titles
// step on the list's width, only in a one-column group, where a post is as
// wide as the list.
const postsVariants = cva("group/post-group @container/post-group", {
  variants: {
    variant: {
      default: "",
      divided: "divide-y divide-foreground/10",
    },
  },
  defaultVariants: { variant: "default" },
})

/**
 * A list of blog posts or articles, rendered as a `<ul>` laid out in
 * columns. Put a `Post` inside for each one.
 *
 * `default` uses up to `cols` columns, three unless you set it. `divided` is
 * one column with a rule between posts. In a one-column group that is at
 * least 36rem wide, `row` posts set their media beside the text and `lg`
 * titles grow. `className` lands on the list, but its columns follow the
 * width of a wrapper around it, so to narrow the list, size its parent.
 */
function PostGroup({
  className,
  cols: colsProp = 3,
  variant = "default",
  ...props
}: ComponentProps<"ul"> &
  (
    | {
        /**
         * `default` sets the posts in columns. `divided` stacks them in one
         * column with a rule between each.
         */
        variant?: "default"
        /**
         * The most columns the posts use once the list is wide enough. A
         * `divided` group is always one column.
         */
        cols?: GridProps["cols"]
      }
    | {
        /**
         * `default` sets the posts in columns. `divided` stacks them in one
         * column with a rule between each.
         */
        variant: "divided"
        /**
         * The most columns the posts use once the list is wide enough. A
         * `divided` group is always one column.
         */
        cols?: 1
      }
  )) {
  const divided = variant === "divided"
  const cols = divided ? 1 : colsProp
  return (
    <Grid
      data-slot="post-group"
      data-variant={variant}
      data-cols={cols}
      render={<ul role="list" />}
      cols={cols}
      gap={divided ? "none" : "lg"}
      className={cn(
        !divided && "gap-y-(--gap-xl)",
        postsVariants({ variant }),
        className
      )}
      // Grid's props are typed for its default div; this group renders a list.
      {...(props as Omit<GridProps, "cols" | "gap">)}
    />
  )
}

// The whole post is the link: the anchor in PostTitle stretches over this box.
// Boxed, it holds a Card (pt-0 for flush media) and the Card fills it.
const postVariants = cva(
  "group/post relative flex min-w-0 flex-col gap-6 rounded-sm group-data-[variant=divided]/post-group:py-12 group-data-[variant=divided]/post-group:first:pt-0 group-data-[variant=divided]/post-group:last:pb-0 has-[a:focus-visible]:ring-2 has-[a:focus-visible]:ring-ring has-[a:focus-visible]:ring-offset-4 has-[a:focus-visible]:ring-offset-background *:data-[slot=card]:flex-1",
  {
    variants: {
      /**
       * The arrangement. `stack` (the default) sets the media above the
       * text. `row` sets the media beside the text, 32px apart, in a
       * one-column `PostGroup` at least 36rem wide; the media frame is then
       * 16rem wide and square. Anywhere else a `row` post stacks.
       */
      variant: {
        stack: "",
        row: "@xl/post-group:group-data-[cols=1]/post-group:flex-row @xl/post-group:group-data-[cols=1]/post-group:gap-8",
      },
      /**
       * The scale. `md` (the default) has an 18px title and a 14px excerpt
       * cut off after three lines. `lg`, for a featured post, has a 30px
       * title, 36px in a one-column `PostGroup` at least 36rem wide, and the
       * full excerpt at 18px. It also narrows the gap below stacked media
       * from 24px to 16px and widens the gap between the text lines from 8px
       * to 16px.
       */
      size: {
        md: "",
        lg: "gap-4",
      },
    },
    defaultVariants: { variant: "stack", size: "md" },
  }
)

/**
 * One post inside `PostGroup`, rendered as an `<li>`. Put a `PostMedia` and a
 * `PostContent` inside, or a `Card` that holds them; a `Card` placed directly
 * inside fills the item. The whole post is clickable: the link in
 * `PostTitle` stretches over it, and the post shows a focus ring while that
 * link has keyboard focus.
 *
 * `row` sets the media beside the text in a one-column group at least 36rem
 * wide. `lg` is for a featured post, with a larger title and the full
 * excerpt. In a `Card`, give the card `pt-0` so `PostMedia` sits flush with
 * its top edge.
 */
function Post({
  className,
  variant = "stack",
  size = "md",
  render,
  ...props
}: useRender.ComponentProps<
  "li",
  { slot: "post" } & VariantProps<typeof postVariants>
> &
  VariantProps<typeof postVariants>) {
  return useRender({
    defaultTagName: "li",
    render,
    state: { slot: "post", variant, size },
    props: mergeProps<"li">(
      { className: cn(postVariants({ variant, size }), className) },
      props
    ),
  })
}

/**
 * The post's image frame, 16:10 with rounded corners. An `<img>` placed
 * directly inside fills and crops to the frame. Inside a `Card` it drops its
 * corners and outline.
 *
 * When a `row` post sets its media beside the text, the frame is 16rem wide
 * and square. Set `--post-media-width` and `--post-media-aspect` to change
 * that.
 */
function PostMedia({ className, ...props }: ComponentProps<"div">) {
  return (
    <div
      data-slot="post-media"
      className={cn(
        "relative aspect-16/10 shrink-0 overflow-hidden rounded-2xl bg-muted ring-1 ring-foreground/5 in-data-[slot=card]:rounded-none in-data-[slot=card]:ring-0 dark:ring-foreground/10 *:[img]:size-full *:[img]:object-cover",
        // Row media: --post-media-width and --post-media-aspect resize it.
        "@xl/post-group:group-data-[cols=1]/post-group:group-data-[variant=row]/post:aspect-(--post-media-aspect,1) @xl/post-group:group-data-[cols=1]/post-group:group-data-[variant=row]/post:w-(--post-media-width,--spacing(64))",
        className
      )}
      {...props}
    />
  )
}

/**
 * The post's text: put a `PostMeta`, a `PostTitle` and a `PostExcerpt`
 * inside. Its lines keep an even gap and stay at the top, even beside taller
 * media in a `row` post.
 */
function PostContent({ className, ...props }: ComponentProps<"div">) {
  return (
    <div
      data-slot="post-content"
      className={cn(
        "grid min-w-0 content-start gap-2 group-data-[size=lg]/post:gap-4",
        className
      )}
      {...props}
    />
  )
}

/**
 * A line of small details about the post, in muted text: put `PostDate`,
 * `PostCategory` and `PostAuthor` inside. Each one after the first draws a
 * dot before itself, and the line wraps when it runs out of room.
 *
 * A link here sits above the title's link that covers the post, so it stays
 * clickable on its own.
 */
function PostMeta({ className, ...props }: ComponentProps<"p">) {
  return (
    <p
      data-slot="post-meta"
      className={cn(
        "flex flex-wrap items-center gap-x-3 gap-y-1 text-sm/6 text-muted-foreground [&_a]:relative [&_a]:z-1",
        className
      )}
      {...props}
    />
  )
}

const META_ITEM =
  "in-data-[slot=post-meta]:not-first:flex in-data-[slot=post-meta]:not-first:items-center in-data-[slot=post-meta]:not-first:gap-3 in-data-[slot=post-meta]:not-first:before:size-0.5 in-data-[slot=post-meta]:not-first:before:rounded-full in-data-[slot=post-meta]:not-first:before:bg-current in-data-[slot=post-meta]:not-first:before:content-['']"

/**
 * The post's date, as a `<time>`. Give it a `dateTime` value that machines
 * can read.
 */
function PostDate({ className, ...props }: ComponentProps<"time">) {
  return (
    <time
      data-slot="post-date"
      className={cn(META_ITEM, className)}
      {...props}
    />
  )
}

/** The post's category, in full-strength text among the muted details. */
function PostCategory({ className, ...props }: ComponentProps<"span">) {
  return (
    <span
      data-slot="post-category"
      className={cn("text-foreground", META_ITEM, className)}
      {...props}
    />
  )
}

/**
 * The post's author. An `<img>` placed directly inside shows as a 24px round
 * avatar beside the name.
 */
function PostAuthor({ className, ...props }: ComponentProps<"span">) {
  return (
    <span
      data-slot="post-author"
      className={cn(
        "flex items-center gap-2 *:[img]:size-6 *:[img]:rounded-full",
        META_ITEM,
        className
      )}
      {...props}
    />
  )
}

type PostTitleState = { slot: "post-title" }

/**
 * The post's heading, an `<h3>` by default. Put your own `<a>` or framework
 * link inside: it stretches over the whole `Post`, so a click anywhere on the
 * post follows it. The title turns muted while the post is hovered.
 *
 * Pass `render` to use another heading level.
 */
function PostTitle({
  className,
  render,
  ...props
}: useRender.ComponentProps<"h3", PostTitleState>) {
  return useRender({
    defaultTagName: "h3",
    render,
    state: { slot: "post-title" },
    props: mergeProps<"h3">(
      {
        className: cn(
          "text-lg/7 font-semibold text-pretty group-hover/post:text-muted-foreground group-data-[size=lg]/post:text-3xl/9 group-data-[size=lg]/post:tracking-tight @xl/post-group:group-data-[cols=1]/post-group:group-data-[size=lg]/post:text-4xl/11 [&_a]:outline-none [&_a]:after:absolute [&_a]:after:inset-0",
          className
        ),
      },
      props
    ),
  })
}

/**
 * A short summary of the post, in muted text, cut off after three lines. In
 * an `lg` post it shows in full at a larger size. Links inside stay clickable
 * on their own.
 */
function PostExcerpt({ className, ...props }: ComponentProps<"p">) {
  return (
    <p
      data-slot="post-excerpt"
      className={cn(
        "line-clamp-3 text-sm/6 text-pretty text-muted-foreground group-data-[size=lg]/post:line-clamp-none group-data-[size=lg]/post:text-lg/8 [&_a]:relative [&_a]:z-1",
        className
      )}
      {...props}
    />
  )
}

export {
  PostGroup,
  Post,
  PostMedia,
  PostContent,
  PostMeta,
  PostDate,
  PostCategory,
  PostAuthor,
  PostTitle,
  PostExcerpt,
}

Installation

pnpm dlx shadcn add @01orbital/post
npx shadcn add @01orbital/post
yarn shadcn add @01orbital/post
bunx --bun shadcn add @01orbital/post

Free under the MIT license. Add the @01orbital registry to components.json once (install guide), then run the command.

It lands at components/editorial/post.tsx, as source you own and edit.

The example also uses section. Install it too:

pnpm dlx shadcn add @01orbital/section
npx shadcn add @01orbital/section
yarn shadcn add @01orbital/section
bunx --bun shadcn add @01orbital/section

Usage

import {
  Post,
  PostAuthor,
  PostContent,
  PostDate,
  PostExcerpt,
  PostGroup,
  PostMedia,
  PostMeta,
  PostTitle,
} from "@/components/editorial/post"
import {
  Section,
  SectionContent,
} from "@/components/editorial/section"

const COPY = {
  items: [
    {
      image: "https://01orbital.dev/art/kit/thomas-v2-596378.png",
      title: "The colors we return to",
      excerpt: "Why a few familiar hues keep returning to our work.",
      date: "2026-09-18",
      dateLabel: "Sep 18, 2026",
      author: "Mae Jemison",
    },
    {
      image: "https://01orbital.dev/art/kit/layered-currents-654353.png",
      title: "From sketch to release",
      excerpt:
        "Small choices, made between a first sketch and the version we released.",
      date: "2026-09-06",
      dateLabel: "Sep 6, 2026",
      author: "Chris Hadfield",
    },
  ],
} as const

export default function PostDemo() {
  return (
    <Section>
      <SectionContent className="items-center">
        <PostGroup cols={2} className="w-full max-w-3xl">
          {COPY.items.map((post) => (
            <Post key={post.title}>
              <PostMedia>
                <img src={post.image} alt="" loading="lazy" />
              </PostMedia>
              <PostContent>
                <PostMeta>
                  <PostDate dateTime={post.date}>{post.dateLabel}</PostDate>
                  <PostAuthor>{post.author}</PostAuthor>
                </PostMeta>
                <PostTitle>
                  <a href="#">{post.title}</a>
                </PostTitle>
                <PostExcerpt>{post.excerpt}</PostExcerpt>
              </PostContent>
            </Post>
          ))}
        </PostGroup>
      </SectionContent>
    </Section>
  )
}

Anatomy

Post is built from 10 parts. In the example they nest like this:

PostGroup
└─ Post
   ├─ PostMedia
   └─ PostContent
      ├─ PostMeta
      │  ├─ PostDate
      │  └─ PostAuthor
      ├─ PostTitle
      └─ PostExcerpt
  • PostGroup: A list of blog posts or articles, rendered as a <ul> laid out in columns. Put a Post inside for each one.
  • Post: One post inside PostGroup, rendered as an <li>. Put a PostMedia and a PostContent inside, or a Card that holds them; a Card placed directly inside fills the item. The whole post is clickable: the link in PostTitle stretches over it, and the post shows a focus ring while that link has keyboard focus.
  • PostMedia: The post's image frame, 16:10 with rounded corners. An <img> placed directly inside fills and crops to the frame. Inside a Card it drops its corners and outline.
  • PostContent: The post's text: put a PostMeta, a PostTitle and a PostExcerpt inside. Its lines keep an even gap and stay at the top, even beside taller media in a row post.
  • PostMeta: A line of small details about the post, in muted text: put PostDate, PostCategory and PostAuthor inside. Each one after the first draws a dot before itself, and the line wraps when it runs out of room.
  • PostDate: The post's date, as a <time>. Give it a dateTime value that machines can read.
  • PostAuthor: The post's author. An <img> placed directly inside shows as a 24px round avatar beside the name.
  • PostTitle: The post's heading, an <h3> by default. Put your own <a> or framework link inside: it stretches over the whole Post, so a click anywhere on the post follows it. The title turns muted while the post is hovered.
  • PostExcerpt: A short summary of the post, in muted text, cut off after three lines. In an lg post it shows in full at a larger size. Links inside stay clickable on their own.
  • PostCategory: The post's category, in full-strength text among the muted details.

API reference

PostGroup

A list of blog posts or articles, rendered as a <ul> laid out in columns. Put a Post inside for each one.

default uses up to cols columns, three unless you set it. divided is one column with a rule between posts. In a one-column group that is at least 36rem wide, row posts set their media beside the text and lg titles grow. className lands on the list, but its columns follow the width of a wrapper around it, so to narrow the list, size its parent.

Prop

Type

It also accepts the HTML attributes of the element it renders.

Post

One post inside PostGroup, rendered as an <li>. Put a PostMedia and a PostContent inside, or a Card that holds them; a Card placed directly inside fills the item. The whole post is clickable: the link in PostTitle stretches over it, and the post shows a focus ring while that link has keyboard focus.

row sets the media beside the text in a one-column group at least 36rem wide. lg is for a featured post, with a larger title and the full excerpt. In a Card, give the card pt-0 so PostMedia sits flush with its top edge.

Prop

Type

It also accepts the props of the Base UI part it wraps, and the HTML attributes of the element it renders.

PostMedia

The post's image frame, 16:10 with rounded corners. An <img> placed directly inside fills and crops to the frame. Inside a Card it drops its corners and outline.

When a row post sets its media beside the text, the frame is 16rem wide and square. Set --post-media-width and --post-media-aspect to change that.

It accepts the HTML attributes of the element it renders.

PostContent

The post's text: put a PostMeta, a PostTitle and a PostExcerpt inside. Its lines keep an even gap and stay at the top, even beside taller media in a row post.

It accepts the HTML attributes of the element it renders.

PostMeta

A line of small details about the post, in muted text: put PostDate, PostCategory and PostAuthor inside. Each one after the first draws a dot before itself, and the line wraps when it runs out of room.

A link here sits above the title's link that covers the post, so it stays clickable on its own.

It accepts the HTML attributes of the element it renders.

PostDate

The post's date, as a <time>. Give it a dateTime value that machines can read.

It accepts the HTML attributes of the element it renders.

PostAuthor

The post's author. An <img> placed directly inside shows as a 24px round avatar beside the name.

It accepts the HTML attributes of the element it renders.

PostTitle

The post's heading, an <h3> by default. Put your own <a> or framework link inside: it stretches over the whole Post, so a click anywhere on the post follows it. The title turns muted while the post is hovered.

Pass render to use another heading level.

It accepts the props of the Base UI part it wraps, and the HTML attributes of the element it renders.

PostExcerpt

A short summary of the post, in muted text, cut off after three lines. In an lg post it shows in full at a larger size. Links inside stay clickable on their own.

It accepts the HTML attributes of the element it renders.

PostCategory

The post's category, in full-strength text among the muted details.

It accepts the HTML attributes of the element it renders.

Dependencies

npm: @base-ui/react, class-variance-authority, cn. The CLI installs them for you.

Registry pieces it installs with it:

  • grid