Meera 5 min ago
Anyone else seeing flaky tests in checkout?
Holds new feed items behind a "5 new posts" pill, so the feed doesn't jump under the reader's finger while they read or tap.
StopsFeed jumped while I was tapping
"use client"
import * as React from "react"
import { NewContentPill } from "@/components/ui/new-content-pill"
interface Post {
id: string
author: string
text: string
minutes: number
}
const authors = ["Priya", "Arjun", "Meera", "Kabir", "Sana", "Dev"]
const lines = [
"Shipped the new search index. Queries are about 3x faster.",
"Reminder: design review moves to 4 pm today.",
"Anyone else seeing flaky tests in checkout?",
"Wrote up the incident from Tuesday. Comments welcome.",
"The staging database restarts at 6 pm.",
"New hire onboarding doc is live in the handbook.",
]
const post = (n: number, minutes: number): Post => ({
id: `p-${n}`,
author: authors[n % authors.length],
text: lines[n % lines.length],
minutes,
})
const initial = Array.from({ length: 14 }, (_, i) => post(14 - i, 5 + i * 7))
export function Demo() {
const [posts, setPosts] = React.useState(initial)
const next = React.useRef(15)
const feedRef = React.useRef<HTMLDivElement>(null)
const started = React.useRef(false)
const arrive = (count: number) => {
const fresh = Array.from({ length: count }, () => post(next.current++, 0)).reverse()
setPosts((current) => [...fresh, ...current])
}
// Open mid-feed, then let three posts arrive so the pill is already holding them.
React.useEffect(() => {
if (started.current) return
started.current = true
feedRef.current?.scrollTo({ top: 260 })
arrive(3)
}, [])
return (
<div className="w-full max-w-sm space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">#engineering</span>
<button
type="button"
onClick={() => arrive(1)}
className="rounded-md border px-2 py-1 text-xs hover:bg-accent outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
Simulate a new post
</button>
</div>
<NewContentPill
ref={feedRef}
items={posts}
getKey={(item) => item.id}
aria-label="#engineering feed"
className="h-80 rounded-xl border bg-background"
renderItem={(item) => (
<article className="flex gap-3 border-b px-3 py-3">
<span aria-hidden className="grid size-8 shrink-0 place-items-center rounded-full bg-muted text-xs font-medium">
{item.author[0]}
</span>
<div className="min-w-0 text-sm">
<p>
<span className="font-medium">{item.author}</span>{" "}
<span className="text-xs text-muted-foreground">{item.minutes ? `${item.minutes} min ago` : "just now"}</span>
</p>
<p className="text-muted-foreground">{item.text}</p>
</div>
</article>
)}
/>
</div>
)
}The files land in your project. They're yours to edit.
Install the dependencies
Copy and paste the following code into components/ui
"use client"
// Bollard UI — new-content-pill · MIT licence · https://bollard-ui.vercel.app/components/new-content-pill
import * as React from "react"
import { ArrowUp } from "lucide-react"
import { cn } from "@/lib/utils"
/**
* Splits a newest-first list into items still held back above the reader and
* items on screen. Anything before the first item already shown is new.
*/
export function splitArrivals<T>(items: T[], getKey: (item: T) => string, shown: ReadonlySet<string>) {
const first = items.findIndex((item) => shown.has(getKey(item)))
return first > 0 ? { queued: items.slice(0, first), visible: items.slice(first) } : { queued: [] as T[], visible: items }
}
const fill = (template: string, count: number) => template.replace("{count}", String(count))
export interface NewContentPillProps<T> extends Omit<React.ComponentProps<"div">, "children"> {
/** The live list, newest first. New items arrive at the start. */
items: T[]
/** A stable key for each item. */
getKey: (item: T) => string
/** Renders one item. */
renderItem: (item: T, index: number) => React.ReactNode
/** Pill text. `{count}` is filled in. */
label?: string
/** Pill text for a single new item. */
labelOne?: string
/** Within this many pixels of the top counts as "at the top", where new items go straight in. */
topThreshold?: number
/**
* What scrolls. "self" (default) makes this element the scroll box. "window" is for feeds where the whole
* page scrolls; a function returns another scrolling ancestor (an app shell's main panel).
*/
scrollTarget?: "self" | "window" | (() => HTMLElement | null)
/** Height of a sticky header over the feed when the page or an ancestor scrolls. The pill sits below it. */
topOffset?: number
}
const KEYFRAMES = `@keyframes cui-new-content-pill-arrive{from{opacity:.3;transform:translateY(-6px);background-color:color-mix(in oklab,var(--color-amber-500) 18%,transparent)}to{opacity:1;transform:none;background-color:transparent}}`
/**
* Stops live feeds shoving content down under the reader's finger. New items
* are held above the reader behind a "5 new posts" pill that inserts them and
* scrolls up on tap. They go straight in only when the reader is already at
* the top and isn't mid-press, so even a tap that's landing can't hit the wrong post.
*/
export function NewContentPill<T>({
items,
getKey,
renderItem,
label = "{count} new posts",
labelOne = "1 new post",
topThreshold = 8,
scrollTarget = "self",
topOffset = 0,
className,
onPointerDown,
onScroll,
ref,
...props
}: NewContentPillProps<T>) {
const [shown, setShown] = React.useState(() => new Set(items.map(getKey)))
const [fresh, setFresh] = React.useState<ReadonlySet<string>>(new Set())
const [pressing, setPressing] = React.useState(false)
const [announced, setAnnounced] = React.useState("")
const rootRef = React.useRef<HTMLDivElement>(null)
const pillRef = React.useRef<HTMLButtonElement>(null)
const scrollUp = React.useRef(false)
const lastCount = React.useRef(0)
const self = scrollTarget === "self"
const scroller = React.useCallback(() => (scrollTarget === "self" ? rootRef.current : scrollTarget === "window" ? null : scrollTarget()), [scrollTarget])
/** How far the reader is below the top of the feed, in px. */
const depth = React.useCallback(() => {
const root = rootRef.current
if (!root) return Infinity
if (scrollTarget === "self") return root.scrollTop
const box = scroller()
return (box ? box.getBoundingClientRect().top : 0) + topOffset - root.getBoundingClientRect().top
}, [scrollTarget, scroller, topOffset])
React.useImperativeHandle(ref, () => rootRef.current as HTMLDivElement)
const { queued, visible } = splitArrivals(items, getKey, shown)
const count = queued.length
const text = fill(count === 1 ? labelOne : label, count)
const queuedKeys = queued.map(getKey).join("\n")
// Keep the last count on the pill while it fades out after a tap.
const pillText = React.useRef(text)
if (count) pillText.current = text
const reveal = React.useCallback((keys: string[], scroll: boolean) => {
scrollUp.current = scroll
setShown((current) => new Set([...current, ...keys]))
setFresh(new Set(keys))
}, [])
// At the top and not pressing anything: new items can go straight in.
React.useEffect(() => {
const root = rootRef.current
if (!queuedKeys || !root || pressing || depth() > topThreshold) return
reveal(queuedKeys.split("\n"), false)
}, [queuedKeys, pressing, topThreshold, reveal, depth])
// Scrolling back up to the top by hand lets held posts in, as if the pill had been tapped.
const onReaderScroll = () => {
if (count && !pressing && depth() <= topThreshold) reveal(queued.map(getKey), false)
}
const latestScroll = React.useRef(onReaderScroll)
latestScroll.current = onReaderScroll
React.useEffect(() => {
if (scrollTarget === "self") return
const target: HTMLElement | Window = scroller() ?? window
const listener = () => latestScroll.current()
target.addEventListener("scroll", listener, { passive: true })
return () => target.removeEventListener("scroll", listener)
}, [scrollTarget, scroller])
// A press holds insertions until just after it ends. The release listener is
// attached in the handler itself, so even a very quick tap can't be missed.
const releaseTimer = React.useRef<ReturnType<typeof setTimeout>>(undefined)
React.useEffect(() => () => clearTimeout(releaseTimer.current), [])
const hold = () => {
clearTimeout(releaseTimer.current)
setPressing(true)
const release = () => {
window.removeEventListener("pointerup", release)
window.removeEventListener("pointercancel", release)
releaseTimer.current = setTimeout(() => setPressing(false), 300)
}
window.addEventListener("pointerup", release)
window.addEventListener("pointercancel", release)
}
// After a tap on the pill, go to the top and put focus on the newest item.
React.useLayoutEffect(() => {
const root = rootRef.current
if (!scrollUp.current || !root) return
scrollUp.current = false
const behavior = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth"
if (self) root.scrollTo({ top: 0, behavior })
else {
// Bring the feed's top under the sticky header, scrolling the page or ancestor rather than the feed.
const box = scroller()
const by = -depth()
if (by < 0) (box ?? window).scrollBy({ top: by, behavior })
}
root.querySelector<HTMLElement>("[data-feed-key]")?.focus({ preventScroll: true })
}, [shown]) // eslint-disable-line react-hooks/exhaustive-deps
// A count that grows while the pill is up gets a small pop; announcements wait for a burst to settle.
React.useEffect(() => {
if (count > lastCount.current && lastCount.current > 0 && !window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
pillRef.current?.animate({ scale: [1, 1.08, 1] }, { duration: 200, easing: "ease-out" })
}
lastCount.current = count
const timer = setTimeout(() => setAnnounced(count ? text : ""), 800)
return () => clearTimeout(timer)
}, [count, text])
return (
<div
ref={rootRef}
data-slot="new-content-pill"
data-state={count ? "queued" : "live"}
data-scroll={self ? "self" : "outer"}
className={cn("relative", self && "overflow-y-auto overscroll-contain", className)}
onPointerDown={(event) => {
hold()
onPointerDown?.(event)
}}
onScroll={(event) => {
onScroll?.(event)
if (self) onReaderScroll()
}}
{...props}
>
<style>{KEYFRAMES}</style>
<div className="pointer-events-none sticky top-0 z-10 h-0" style={topOffset ? { top: topOffset } : undefined}>
<div
className={cn(
"absolute inset-x-0 top-2 flex justify-center transition-[opacity,translate] duration-200 ease-out motion-reduce:transition-none",
count ? "translate-y-0 opacity-100" : "invisible -translate-y-2 opacity-0",
)}
>
<button
ref={pillRef}
type="button"
onClick={() => reveal(queued.map(getKey), true)}
className="pointer-events-auto inline-flex h-8 items-center gap-1.5 rounded-full bg-primary px-3 text-sm font-medium whitespace-nowrap text-primary-foreground shadow-md outline-none transition-colors hover:bg-primary/90 focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<ArrowUp aria-hidden className="size-4" />
{pillText.current}
</button>
</div>
</div>
<span role="status" className="sr-only">
{announced}
</span>
{visible.map((item, index) => {
const key = getKey(item)
return (
<div
key={key}
data-feed-key={key}
tabIndex={-1}
className={cn("outline-none", fresh.has(key) && "motion-safe:animate-[cui-new-content-pill-arrive_400ms_ease-out]")}
>
{renderItem(item, index)}
</div>
)
})}
</div>
)
}Update the import paths to match your project setup.
It uses the cn() helper from @/lib/utils, which every shadcn/ui project already has.
| Prop | Type | Default |
|---|---|---|
items* (required)The live list, newest first. New items arrive at the start. | T[] | — |
getKey* (required)A stable key for each item. | (item: T) => string | — |
renderItem* (required)Renders one item. | (item: T, index: number) => React.ReactNode | — |
labelPill text. `{count}` is filled in. | string | "{count} new posts" |
labelOnePill text for a single new item. | string | "1 new post" |
topThresholdWithin this many pixels of the top counts as "at the top", where new items go straight in. | number | 8 |
scrollTargetWhat scrolls. "self" (default) makes this element the scroll box. "window" is for feeds where the whole page scrolls; a function returns another scrolling ancestor (an app shell's main panel). | "self" | "window" | (() => HTMLElement | null) | "self" |
topOffsetHeight of a sticky header over the feed when the page or an ancestor scrolls. The pill sits below it. | number | 0 |
* required. Also accepts the native props of <div> (className, aria-*, data-*…), passed through to it.
Only what the source does. Test with your own screen reader and content before shipping.
Feed jumped while I was tapping
How people describe it
Also known as
new posts · feed · timeline · infinite scroll · live updates · chat · layout shift · pill
Meera 5 min ago
Anyone else seeing flaky tests in checkout?
Arjun 12 min ago
Reminder: design review moves to 4 pm today.
Priya 19 min ago
Shipped the new search index. Queries are about 3x faster.
Dev 26 min ago
New hire onboarding doc is live in the handbook.
Sana 33 min ago
The staging database restarts at 6 pm.
Kabir 40 min ago
Wrote up the incident from Tuesday. Comments welcome.
Meera 47 min ago
Anyone else seeing flaky tests in checkout?
Arjun 54 min ago
Reminder: design review moves to 4 pm today.
Priya 61 min ago
Shipped the new search index. Queries are about 3x faster.
Dev 68 min ago
New hire onboarding doc is live in the handbook.
Sana 75 min ago
The staging database restarts at 6 pm.
Kabir 82 min ago
Wrote up the incident from Tuesday. Comments welcome.
Meera 89 min ago
Anyone else seeing flaky tests in checkout?
Arjun 96 min ago
Reminder: design review moves to 4 pm today.