Media

Live Blog Feed

A live blog with pinned entries and incoming updates that expand in at the top via a grid-rows animation.

Install

npx shadcn@latest add @paragon/live-blog-feed

live-blog-feed.tsx

"use client";

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

export interface LiveBlogEntry {
  id: string;
  /** Timestamp string, pre-formatted for determinism. */
  time: string;
  title: string;
  body: string;
  pinned?: boolean;
}

export interface LiveBlogFeedProps extends React.ComponentProps<"div"> {
  /** Newest first (excluding pinned, which float to the top). */
  entries: LiveBlogEntry[];
  /** Renders entries in place, no expand-in. */
  static?: boolean;
}

/**
 * A live blog. Pinned entries float to the top with a marker; incoming updates
 * expand in above the rest by animating grid-template-rows 0fr to 1fr (the one
 * sanctioned height animation) plus opacity, so a new post pushes the list down
 * smoothly instead of popping. New entries are keyed by id so replays and live
 * pushes both animate. Reduced motion drops the expand, keeping the fade.
 */
export function LiveBlogFeed({
  entries,
  static: isStatic = false,
  className,
  ...props
}: LiveBlogFeedProps) {
  const reducedMotion = useReducedMotion();
  const animate = !isStatic && !reducedMotion;

  const pinned = entries.filter((e) => e.pinned);
  const rest = entries.filter((e) => !e.pinned);

  return (
    <div
      data-slot="live-blog-feed"
      className={cn(
        "flex w-full max-w-md flex-col gap-2 rounded-xl bg-card p-3 shadow-border",
        className,
      )}
      {...props}
    >
      {pinned.map((entry) => (
        <Entry key={entry.id} entry={entry} pinned />
      ))}
      {pinned.length > 0 && rest.length > 0 && (
        <div className="h-px bg-border" aria-hidden />
      )}

      <div className="flex flex-col gap-2">
        <AnimatePresence initial={false}>
          {rest.map((entry) => (
            <motion.div
              key={entry.id}
              layout={animate ? "position" : false}
              initial={
                animate
                  ? { gridTemplateRows: "0fr", opacity: 0 }
                  : false
              }
              animate={{ gridTemplateRows: "1fr", opacity: 1 }}
              exit={
                animate
                  ? { gridTemplateRows: "0fr", opacity: 0 }
                  : { opacity: 0 }
              }
              transition={{
                gridTemplateRows: {
                  type: "spring",
                  duration: 0.4,
                  bounce: 0,
                },
                opacity: { duration: 0.2, ease: [0.22, 1, 0.36, 1] },
              }}
              className="grid"
              style={{ gridTemplateRows: animate ? undefined : "1fr" }}
            >
              <div className="overflow-hidden">
                <Entry entry={entry} />
              </div>
            </motion.div>
          ))}
        </AnimatePresence>
      </div>
    </div>
  );
}

function Entry({
  entry,
  pinned = false,
}: {
  entry: LiveBlogEntry;
  pinned?: boolean;
}) {
  return (
    <article
      className={cn(
        "rounded-lg px-3 py-2.5",
        pinned && "bg-accent",
      )}
    >
      <div className="flex items-center gap-2 text-xs text-muted-foreground">
        {pinned && (
          <span className="inline-flex items-center gap-1 font-medium text-foreground">
            <Pin className="size-3" />
            Pinned
          </span>
        )}
        <time className="tabular-nums">{entry.time}</time>
      </div>
      <h4 className="mt-1 text-sm font-semibold">{entry.title}</h4>
      <p className="mt-0.5 text-sm text-muted-foreground">{entry.body}</p>
    </article>
  );
}