## New Items Pill — Notification New content without stealing your scroll. Docs: https://www.interior.dev/docs/new-items-pill Reference: https://www.interior.dev/reference/new-items-pill License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/new-items-pill.json` Or run `bun add motion` and copy the source below. ### Usage ```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(initial); const { scrollProps, unread, jump } = useNewItems({ 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 (
{posts.map((p) => (

{p.author}

{p.body}

))}
`${n} new ${n === 1 ? "post" : "posts"}`} />
); } ``` ### 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" | "bottom"`) — default: `"top"` Which edge new items arrive at. Sets where the pill sits, which way its arrow points, and the direction it enters from. - `label` (`(count: number) => string`) — default: `n => `${n} new item(s)`` Builds the visible text and the accessible name from the count. Pluralise here; nothing downstream guesses at grammar. - `max` (`number`) — default: `99` Counts above this render as "99+" so the pill cannot grow without bound on a busy stream. - `className` (`string`) — default: `""` 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" | "bottom"`) — default: `"top"` "top" compensates the scroll offset when items are prepended; "bottom" follows the tail only while the reader is already at it. - `threshold` (`number`) — default: `24` useNewItems option. Pixels from the anchored edge still counted as being at the edge, so a one-pixel drift does not start buffering. ### Behavior notes - Prepending to a scrolled list normally shoves the line you were reading down the screen; the hook records the distance from the reading position to the end of the list and restores it in the same frame the new items commit, and sets overflow-anchor: none so the browser's own anchoring cannot correct it a second time. - The pill is absolutely positioned over the scroller, so neither its arrival nor a count crossing from one digit to three ever reflows a single row underneath it. It arrives from the edge the items arrived from and leaves the same way. - Jumping returns how many had piled up, so the rows you were called back for can be marked once you get there. Being told there are nine new posts and then dropped at the top with no idea which nine is half an answer. - Focus is the brand blue border and the surface lifting, never a ring. A halo on top of a border and a shadow is the third signal nobody asked for. - Scroll offset is read into a ref on a passive listener; React re-renders only when the pinned boolean flips or the buffered count changes, never once per scroll event. - Scrolling back to the edge yourself clears the count without a click, so the pill never sits over content you have already reached. - Screen readers get one settled count 700ms after the last arrival instead of one announcement per item, and the visible text is hidden from them so the button is named once rather than twice. - Under prefers-reduced-motion the pill still appears and still states the count, only crossing in on opacity, and the jump becomes an instant scroll; either way focus lands on the scroll container before the pill unmounts, so it is never dropped to the body. ### Source (`components/interior/new-items-pill.tsx`) ```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 = { scrollProps: { ref: React.RefObject; tabIndex: number; style: React.CSSProperties; }; unread: number; pinned: boolean; jump: () => number; }; export function useNewItems({ itemCount, anchor = "top", threshold = 24, }: UseNewItemsOptions): UseNewItemsResult { const ref = useRef(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 (
{count > 0 && ( )} {announced > 0 ? phrase(announced) : ""}
); } ```