components / conversation
Scroll to Bottom
A jump-to-latest button that appears when a scroll container is not at the bottom. Pairs with useAutoScroll.
Message 1. Scroll up and the latest button appears at the bottom edge.
Message 2. Scroll up and the latest button appears at the bottom edge.
Message 3. Scroll up and the latest button appears at the bottom edge.
Message 4. Scroll up and the latest button appears at the bottom edge.
Message 5. Scroll up and the latest button appears at the bottom edge.
Message 6. Scroll up and the latest button appears at the bottom edge.
Message 7. Scroll up and the latest button appears at the bottom edge.
Message 8. Scroll up and the latest button appears at the bottom edge.
Message 9. Scroll up and the latest button appears at the bottom edge.
Message 10. Scroll up and the latest button appears at the bottom edge.
Message 11. Scroll up and the latest button appears at the bottom edge.
Message 12. Scroll up and the latest button appears at the bottom edge.
Message 13. Scroll up and the latest button appears at the bottom edge.
Message 14. Scroll up and the latest button appears at the bottom edge.
install
npx shadcn@latest add https://turnui.xyz/r/scroll-to-bottom.jsonprops
| prop | type | default | description |
|---|---|---|---|
| target | RefObject<HTMLElement | null> | — | The scrolling element, for example the ref from useAutoScroll. |
| threshold | number | 120 | Distance from the bottom, in pixels, before the button appears. |
| label | string | "Scroll to latest message" | Accessible name. |
accessibility
- The button is only in the DOM while the container is away from the bottom, so it is never an unreachable tab stop.
- It has an accessible name that contains its visible text ("latest").
- After jumping, focus moves to the scroll container so it is not dropped on the page. The container gets tabindex=-1 if it has none.
- Scrolling is instant when the user prefers reduced motion.
source
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Button } from "./button";
/**
* A "jump to latest" button that appears when a scroll container is not at the bottom.
* Place it inside a `relative` wrapper around the container; it centers itself near the bottom edge.
*/
export function ScrollToBottom({
target,
threshold = 120,
label = "Scroll to latest message",
className,
}: {
/** The scrolling element, e.g. the ref returned by useAutoScroll. */
target: React.RefObject<HTMLElement | null>;
/** How far from the bottom, in pixels, before the button appears. */
threshold?: number;
/** Accessible name. Keep the word "latest" so it contains the visible text. */
label?: string;
className?: string;
}) {
const [away, setAway] = React.useState(false);
React.useEffect(() => {
const el = target.current;
if (!el) return;
const update = () => setAway(el.scrollHeight - el.scrollTop - el.clientHeight > threshold);
const first = requestAnimationFrame(update);
el.addEventListener("scroll", update, { passive: true });
const resize = typeof ResizeObserver !== "undefined" ? new ResizeObserver(update) : undefined;
resize?.observe(el);
const mutate = new MutationObserver(update);
mutate.observe(el, { childList: true, subtree: true, characterData: true });
return () => {
cancelAnimationFrame(first);
el.removeEventListener("scroll", update);
resize?.disconnect();
mutate.disconnect();
};
}, [target, threshold]);
function jump() {
const el = target.current;
if (!el) return;
const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
el.scrollTo({ top: el.scrollHeight, behavior: reduce ? "auto" : "smooth" });
// The button unmounts once the container is at the bottom, so hand focus to the container.
if (!el.hasAttribute("tabindex")) el.setAttribute("tabindex", "-1");
el.focus({ preventScroll: true });
}
if (!away) return null;
return (
<Button
type="button"
variant="outline"
size="sm"
aria-label={label}
onClick={jump}
className={cn("absolute bottom-3 left-1/2 -translate-x-1/2 gap-1.5 bg-background font-mono text-xs", className)}
>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M8 3v10M3.5 8.5 8 13l4.5-4.5" />
</svg>
latest
</Button>
);
}