interior[.]dev

Notification·04.5

New Items Pill

New content without stealing your scroll.

Milo left a note on the pricing page
Kaz shipped the scroll restoration fix
Noor closed the flaky test issue
Iris renamed the staging cluster
Ada published the changelog
Wren moved two tickets to review
Otto left a note on the pricing page
Sena shipped the scroll restoration fix
Milo closed the flaky test issue
Kaz renamed the staging cluster
Noor published the changelog
Iris moved two tickets to review
Ada left a note on the pricing page
Wren shipped the scroll restoration fix
new-items-pill

Install

One dependency. The component is copied into your project, so the file is yours after that.

terminal
bun add motion

Usage

stats.tsx
"use client";

import { useEffect, useState } from "react";
import { NewItemsPill, useNewItems } from "@/components/interior/new-items-pill";

type Post = { id: string; author: string; body: string };

export function Timeline({ initial }: { initial: Post[] }) {
  const [posts, setPosts] = useState<Post[]>(initial);
  const { scrollProps, unread, jump } = useNewItems<HTMLDivElement>({
    itemCount: posts.length,
    anchor: "top",
  });

  useEffect(() => {
    const source = new EventSource("/api/timeline");
    source.onmessage = (e) =>
      setPosts((prev) => [JSON.parse(e.data) as Post, ...prev]);
    return () => source.close();
  }, []);

  return (
    <div className="relative">
      <div
        {...scrollProps}
        role="region"
        aria-label="Timeline"
        className="h-[520px] overflow-y-auto rounded-[14px] border border-stone-200 dark:border-white/[0.16]"
      >
        {posts.map((p) => (
          <article key={p.id} className="border-b border-stone-200 p-4 dark:border-white/[0.16]">
            <p className="text-[13px] font-medium text-stone-700 dark:text-stone-200">{p.author}</p>
            <p className="mt-1 text-[13px] text-stone-500 dark:text-stone-400">{p.body}</p>
          </article>
        ))}
      </div>

      <NewItemsPill
        count={unread}
        onJump={jump}
        label={(n) => `${n} new ${n === 1 ? "post" : "posts"}`}
      />
    </div>
  );
}

Source

components/interior/new-items-pill.tsx
"use client";

import {
  useCallback,
  useEffect,
  useLayoutEffect,
  useRef,
  useState,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

const EASE = [0.23, 1, 0.32, 1] as const;

const ARRIVE = { type: "spring", stiffness: 540, damping: 34, mass: 0.5 } as const;
const INSTANT = { duration: 0 } as const;

const useIsoLayoutEffect =
  typeof window === "undefined" ? useEffect : useLayoutEffect;

export type NewItemsAnchor = "top" | "bottom";

export type UseNewItemsOptions = {
  itemCount: number;
  anchor?: NewItemsAnchor;
  threshold?: number;
};

export type UseNewItemsResult<T extends HTMLElement> = {
  scrollProps: {
    ref: React.RefObject<T | null>;
    tabIndex: number;
    style: React.CSSProperties;
  };
  unread: number;
  pinned: boolean;

  jump: () => number;
};

export function useNewItems<T extends HTMLElement = HTMLDivElement>({
  itemCount,
  anchor = "top",
  threshold = 24,
}: UseNewItemsOptions): UseNewItemsResult<T> {
  const ref = useRef<T | null>(null);
  const pinnedRef = useRef(true);
  const prevCount = useRef(itemCount);
  const bottomGap = useRef(0);

  const [unread, setUnread] = useState(0);
  const [pinned, setPinned] = useState(true);
  const reduced = useReducedMotion();

  useEffect(() => {
    const el = ref.current;
    if (!el) return;

    const read = () =>
      anchor === "bottom"
        ? el.scrollHeight - el.scrollTop - el.clientHeight <= threshold
        : el.scrollTop <= threshold;

    const onScroll = () => {
      bottomGap.current = el.scrollHeight - el.scrollTop;
      const next = read();
      if (next === pinnedRef.current) return;
      pinnedRef.current = next;
      setPinned(next);
      if (next) setUnread(0);
    };
    onScroll();
    el.addEventListener("scroll", onScroll, { passive: true });
    return () => el.removeEventListener("scroll", onScroll);
  }, [anchor, threshold]);

  useIsoLayoutEffect(() => {
    const el = ref.current;
    const added = itemCount - prevCount.current;
    prevCount.current = itemCount;
    if (!el || added <= 0) return;

    if (pinnedRef.current) {
      el.scrollTop = anchor === "bottom" ? el.scrollHeight : 0;
      bottomGap.current = el.scrollHeight - el.scrollTop;
      return;
    }

    if (anchor === "top") {
      const target = el.scrollHeight - bottomGap.current;
      if (target > el.scrollTop) el.scrollTop = target;
    }
    setUnread((n) => n + added);
  }, [itemCount, anchor]);

  const unreadRef = useRef(0);
  unreadRef.current = unread;

  const jump = useCallback(() => {
    const el = ref.current;
    const caught = unreadRef.current;
    if (!el) return caught;
    pinnedRef.current = true;
    setPinned(true);
    setUnread(0);

    el.focus({ preventScroll: true });
    el.scrollTo({
      top: anchor === "bottom" ? el.scrollHeight : 0,
      behavior: reduced ? "auto" : "smooth",
    });
    return caught;
  }, [anchor, reduced]);

  return {
    scrollProps: { ref, tabIndex: 0, style: { overflowAnchor: "none" } },
    unread,
    pinned,
    jump,
  };
}

export type NewItemsPillProps = {
  count: number;
  onJump: () => void;
  anchor?: NewItemsAnchor;
  label?: (count: number) => string;
  max?: number;
  className?: string;
};

const defaultLabel = (n: number) => `${n} new ${n === 1 ? "item" : "items"}`;

export function NewItemsPill({
  count,
  onJump,
  anchor = "top",
  label = defaultLabel,
  max = 99,
  className = "",
}: NewItemsPillProps) {
  const reduced = useReducedMotion();
  const [announced, setAnnounced] = useState(0);

  useEffect(() => {
    if (count === 0) {
      setAnnounced(0);
      return;
    }
    const t = setTimeout(() => setAnnounced(count), 700);
    return () => clearTimeout(t);
  }, [count]);

  const phrase = (n: number) => (n > max ? `${max}+ new items` : label(n));
  const text = phrase(count);
  const off = anchor === "bottom" ? 10 : -10;

  return (
    <div
      className={`pointer-events-none absolute inset-x-0 z-10 flex justify-center ${
        anchor === "bottom" ? "bottom-2" : "top-2"
      } ${className}`}
    >
      <AnimatePresence initial={false}>
        {count > 0 && (
          <motion.button
            type="button"
            onClick={onJump}
            aria-label={text}
            initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.94, y: off }}
            animate={{ opacity: 1, scale: 1, y: 0 }}
            exit={
              reduced
                ? { opacity: 0, transition: INSTANT }
                : {
                    opacity: 0,
                    scale: 0.96,
                    y: off * 0.5,
                    transition: { duration: 0.16, ease: EASE },
                  }
            }
            transition={
              reduced
                ? INSTANT
                : { ...ARRIVE, opacity: { duration: 0.16, ease: EASE } }
            }
            className="pointer-events-auto inline-flex h-8 select-none items-center gap-1.5 rounded-[9px] border border-stone-200 bg-white pl-2 pr-2.5 text-[12.5px] font-medium text-stone-700 shadow-[0_1px_2px_rgba(28,25,23,0.08),0_6px_14px_-10px_rgba(28,25,23,0.45)] outline-none transition-[border-color,box-shadow] duration-150 focus-visible:border-[#4568FF] focus-visible:shadow-[0_2px_4px_rgba(28,25,23,0.1),0_12px_22px_-12px_rgba(69,104,255,0.55)] dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:text-stone-100 dark:shadow-[0_2px_8px_rgba(0,0,0,0.5)] dark:focus-visible:border-[#93B0FF] dark:focus-visible:shadow-[0_2px_10px_rgba(0,0,0,0.6),0_12px_22px_-12px_rgba(147,176,255,0.4)]"
          >
            <svg
              width="14"
              height="14"
              viewBox="0 0 256 256"
              fill="none"
              aria-hidden="true"
              className={anchor === "bottom" ? "rotate-180" : ""}
            >
              <line
                x1="128"
                y1="216"
                x2="128"
                y2="48"
                stroke="currentColor"
                strokeWidth="16"
                strokeLinecap="round"
              />
              <polyline
                points="56 120 128 48 200 120"
                stroke="currentColor"
                strokeWidth="16"
                strokeLinecap="round"
                strokeLinejoin="round"
              />
            </svg>
            <span className="tabular-nums" aria-hidden="true">
              {text}
            </span>
          </motion.button>
        )}
      </AnimatePresence>
      <span role="status" aria-live="polite" className="sr-only">
        {announced > 0 ? phrase(announced) : ""}
      </span>
    </div>
  );
}

Props

count
number

Unread arrivals to advertise. The pill is absent at 0 and enters when it crosses 1. Feed it `unread` from useNewItems.

onJump
() => void

Fired on click or Enter/Space. Pass `jump` from useNewItems, which scrolls to the anchored edge, clears the count and moves focus to the scroll container. It returns how many had piled up, so the caller can mark what just arrived.

anchor"top"
"top" | "bottom"

Which edge new items arrive at. Sets where the pill sits, which way its arrow points, and the direction it enters from.

labeln => `${n} new item(s)`
(count: number) => string

Builds the visible text and the accessible name from the count. Pluralise here; nothing downstream guesses at grammar.

max99
number

Counts above this render as "99+" so the pill cannot grow without bound on a busy stream.

className""
string

Appended last to the absolutely positioned wrapper, so callers can move the pill or change its inset without editing the file.

itemCount
number

useNewItems option. The current length of the rendered list. Every increase is treated as an arrival; decreases are ignored.

useNewItems.anchor"top"
"top" | "bottom"

"top" compensates the scroll offset when items are prepended; "bottom" follows the tail only while the reader is already at it.

threshold24
number

useNewItems option. Pixels from the anchored edge still counted as being at the edge, so a one-pixel drift does not start buffering.