freeNew103 lines · framer-motion
Changelog Feed
Always moving, newest first, no invented dates. A full-width feature section: a vertical changelog timeline where each entry carries a tag and a relative position (Latest, Before that, Earlier still) instead of a made-up date or version number, fading and sliding in staggered as it scrolls into view.
- Use it for
- A product or changelog page that wants to show steady progress without publishing fabricated version numbers or dates.
- Themes
- Light + dark
- License
- Commercial use
Live
Changelog
Always moving, one small step at a time
The most recent changes, newest first.
- NewLatest
Streaming answers
Responses now arrive as they're written instead of all at once.
- ImprovedBefore that
Faster first response
The first reply on a fresh session lands noticeably sooner.
- FixedEarlier still
Lost scroll position
Switching tabs no longer resets you back to the top.
- NewEarlier still
Keyboard shortcuts
Every primary action now has a shortcut, listed in the menu.
tsx
"use client";
import * as React from "react";
import { motion, useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";
function useReducedMotionSafe() {
const prefersReduced = useReducedMotion();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
return mounted ? !!prefersReduced : false;
}
/**
* Full-width feature section: a vertical changelog timeline. Entries carry a tag and
* a relative position ("Latest", "Before that"...) instead of invented dates or
* version numbers, and fade/slide in staggered as they scroll into view.
*/
export interface ChangelogEntry {
tag: "New" | "Improved" | "Fixed";
title: string;
detail: string;
/** Relative position in time, not a date or version number. */
when: string;
}
export interface SectionChangelogFeedProps {
eyebrow?: string;
headline?: string;
subcopy?: string;
entries?: ChangelogEntry[];
/** Token used for timeline dots and tag accents. */
accent?: "signal" | "info" | "lime" | "ok";
/** Stagger speed multiplier. */
speed?: number;
className?: string;
}
const DEFAULT_ENTRIES: ChangelogEntry[] = [
{ tag: "New", title: "Streaming answers", detail: "Responses now arrive as they're written instead of all at once.", when: "Latest" },
{ tag: "Improved", title: "Faster first response", detail: "The first reply on a fresh session lands noticeably sooner.", when: "Before that" },
{ tag: "Fixed", title: "Lost scroll position", detail: "Switching tabs no longer resets you back to the top.", when: "Earlier still" },
{ tag: "New", title: "Keyboard shortcuts", detail: "Every primary action now has a shortcut, listed in the menu.", when: "Earlier still" },
];
const TAG_CLASS: Record<ChangelogEntry["tag"], string> = {
New: "bg-signal/15 text-signal",
Improved: "bg-info/15 text-info",
Fixed: "bg-ok/15 text-ok",
};
export function SectionChangelogFeed({
eyebrow = "Changelog",
headline = "Always moving, one small step at a time",
subcopy = "The most recent changes, newest first.",
entries = DEFAULT_ENTRIES,
accent = "signal",
speed = 1,
className,
}: SectionChangelogFeedProps) {
const reduce = useReducedMotionSafe();
const dotClass = { signal: "bg-signal", info: "bg-info", lime: "bg-lime", ok: "bg-ok" }[accent];
return (
<section className={cn("relative isolate bg-background px-4 py-20 sm:px-6", className)}>
<div className="mx-auto max-w-2xl text-center">
{eyebrow && (
<span className="inline-flex items-center gap-2 rounded-full border border-border bg-card px-3 py-1.5 font-mono text-[11px] uppercase tracking-[0.16em] text-muted-foreground">
<span className="size-1.5 rounded-full bg-signal" aria-hidden />
{eyebrow}
</span>
)}
<h2 className="mt-4 font-display text-[clamp(2rem,5vw,3.2rem)] font-extrabold leading-[1.05] tracking-[-0.03em]">{headline}</h2>
{subcopy && <p className="mt-4 text-base leading-relaxed text-muted-foreground sm:text-lg">{subcopy}</p>}
</div>
<ol className="relative mx-auto mt-12 max-w-2xl space-y-8 border-l border-border pl-8">
{entries.map((entry, i) => (
<motion.li
key={`${entry.title}-${i}`}
className="relative"
initial={reduce ? false : { opacity: 0, x: 14 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, amount: 0.5 }}
transition={{ duration: 0.4, delay: reduce ? 0 : (i * 0.1) / speed }}
>
<span className={cn("absolute -left-[2.15rem] top-1.5 size-2.5 rounded-full ring-4 ring-background", dotClass)} aria-hidden />
<div className="flex flex-wrap items-center gap-2">
<span className={cn("rounded-full px-2.5 py-0.5 text-[11px] font-bold uppercase tracking-wide", TAG_CLASS[entry.tag])}>{entry.tag}</span>
<span className="font-mono text-[11px] uppercase tracking-[0.14em] text-muted-foreground">{entry.when}</span>
</div>
<h3 className="mt-2 text-base font-bold sm:text-lg">{entry.title}</h3>
<p className="mt-1 text-sm leading-relaxed text-muted-foreground sm:text-base">{entry.detail}</p>
</motion.li>
))}
</ol>
</section>
);
}
export default SectionChangelogFeed;
How it behaves
- →Each entry slides and fades in from the side the first time it scrolls into view, staggered top to bottom by speed.
- →Entries use a tag (New / Improved / Fixed) and a relative when label instead of a date or version string, so nothing on the timeline is a fabricated figure.
- →A vertical rule with coloured dots ties every entry to the timeline; dot colour follows the chosen accent token.
- →entries accepts any list of { tag, title, detail, when }, so the same timeline works for any changelog.
- →prefers-reduced-motion renders every entry already in place with no stagger animation.
Accessibility
- ✓The feed is a real ordered list (<ol>/<li>); tag, title and detail are all plain readable text.
- ✓Tag colour is paired with the tag's own text label, never colour alone.
- ✓Honours prefers-reduced-motion.