Skip to content
Black FridayBlack Friday launch price: Lifetime $149 until 30 Nov. Regular price $299 from 1 Dec.ends in 15d 03:13:32Get Lifetime →
loopkit
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.

  1. NewLatest

    Streaming answers

    Responses now arrive as they're written instead of all at once.

  2. ImprovedBefore that

    Faster first response

    The first reply on a fresh session lands noticeably sooner.

  3. FixedEarlier still

    Lost scroll position

    Switching tabs no longer resets you back to the top.

  4. 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.