A live blog with pinned entries and incoming updates that expand in at the top via a grid-rows animation.
npx shadcn@latest add @paragon/live-blog-feed"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>
);
}