From f7304837dc64ec5ec03bf97a2eef7086325555db Mon Sep 17 00:00:00 2001 From: Rasmus Viloma Date: Mon, 12 Oct 2026 23:42:37 +0300 Subject: [PATCH 01/10] feat(thread): time smooth scrolls from CSS (CHAT-44) Smooth scrolls used the browser's native scroll, whose duration and easing can't be configured. Setting --thread-scroll ( ), its longhands, or --thread-scroll-speed on the root now animates every smooth scroll the thread starts: the land, the follow, scrollToBottom, scrollToTop and scrollToMessage. The browser's Web Animations API evaluates the easing, so any CSS easing works, including a Motion spring() printed as linear(). With no variables set, scrolls stay native; `--thread-scroll: none` opts back out over any longhand. While a timed scroll runs, the root carries data-scrolling naming its kind. It is set before the timing is read, so selectors can time each kind on its own. The destination is re-read every frame, so a reply that streams in during a long land is carried along instead of restarting the scroll; in "jump" the land's destination is fixed. Any wheel, touch, scroll key or scrollbar press stops the animation and re-reads the bottom edge, so a drag away releases the follow as it would natively. The styled Thread eases out over 400ms and pins the follow instantly unless `animateFollow` is set: measured in Chrome, an animated follow trailed the newest text further below the fold for no smoothing once the main thread was busy. The playground gets Scroll timing, Duration, Speed and Animate follow settings, and the docs a section and demo. Also fixes two existing bugs: isAtBottom no longer sticks after scrollToTop or scrollToMessage leaves the bottom, and reduced motion resolves to "instant" so a CSS scroll-behavior can't animate it. Co-Authored-By: Claude Opus 5.5 --- .changeset/thread-scroll-timing.md | 19 + components/ai/thread.tsx | 23 +- components/chat.tsx | 40 +- components/playground-settings.tsx | 154 ++++++- content/docs/primitives/thread.mdx | 69 +++ .../primitives/thread/demos/scroll-timing.tsx | 218 +++++++++ lib/store/settings.ts | 26 +- packages/ui/src/thread/geometry.ts | 7 +- packages/ui/src/thread/index.ts | 1 + packages/ui/src/thread/scroll-animation.ts | 184 ++++++++ packages/ui/src/thread/thread.tsx | 250 ++++++++-- .../ui/tests/thread-scroll-animation.test.tsx | 436 ++++++++++++++++++ 12 files changed, 1363 insertions(+), 64 deletions(-) create mode 100644 .changeset/thread-scroll-timing.md create mode 100644 content/docs/primitives/thread/demos/scroll-timing.tsx create mode 100644 packages/ui/src/thread/scroll-animation.ts create mode 100644 packages/ui/tests/thread-scroll-animation.test.tsx diff --git a/.changeset/thread-scroll-timing.md b/.changeset/thread-scroll-timing.md new file mode 100644 index 00000000..192f6f2b --- /dev/null +++ b/.changeset/thread-scroll-timing.md @@ -0,0 +1,19 @@ +--- +"@intentface/chat": minor +--- + +`Thread` scroll timing is now set from CSS. + +**Timed smooth scrolls.** Set `--thread-scroll: ` on the thread root (or the `--thread-scroll-duration` / `--thread-scroll-easing` longhands) and every smooth scroll — the landing, the follow, `scrollToBottom`, `scrollToTop`, `scrollToMessage` — is animated by the thread with that timing. Any CSS easing works, `linear()` included, so a Motion `spring()` string drops in as-is. `--thread-scroll-speed` paces the duration by distance instead, capped by the duration when both are set. + +**`data-scrolling` on the root.** While a timed scroll runs, the root carries `data-scrolling="land" | "follow" | "bottom" | "top" | "message"`. It is set before the timing is read, so selectors can give each kind its own timing. + +**Streaming mid-flight.** The destination is re-read every frame, so a reply that starts streaming during a long landing is carried along instead of restarting the scroll. In `"jump"` the landing's destination is fixed when it starts. Any wheel, touch, scroll key or scrollbar press stops the animation where it is; under `prefers-reduced-motion` every scroll is instant. + +**Opting out.** `--thread-scroll: none` keeps the native smooth scroll over any longhand, so a thread can opt back out of a timing set further up. + +Nothing changes without the variables: smooth scrolls stay native. + +**Fixes along the way.** +- `isAtBottom` / `data-at-bottom` no longer stay stuck after `scrollToTop` or `scrollToMessage` moves away from the bottom: the bottom edge leaving view is no longer swallowed as a scroll of the thread's own. +- Under `prefers-reduced-motion`, scrolls are instant even when the scroller's CSS sets `scroll-behavior: smooth` (they resolved to `auto`, which defers to it). diff --git a/components/ai/thread.tsx b/components/ai/thread.tsx index e9da5443..e2fb92e1 100644 --- a/components/ai/thread.tsx +++ b/components/ai/thread.tsx @@ -23,12 +23,31 @@ export type { ThreadAutoScrollMode, ThreadVisibilityState }; // Root // --------------------------------------------------------------------------- -export type ThreadRootProps = ComponentProps; +export type ThreadRootProps = ComponentProps & { + /** + * Animate the follow with the thread's scroll timing instead of pinning it. + * Off by default: see SCROLL_TIMING. + */ + animateFollow?: boolean; +}; -const ThreadRoot = ({ className, ...props }: ThreadRootProps) => ( +// Scroll timing: every smooth scroll eases out over 400ms, except the follow, +// which pins instantly. A reply grows a line at a time, so pinning reads as +// smooth already, and it leaves no animation on the main thread while it is +// busiest rendering the stream. The follow rule is !important so a timing set +// inline (`style`) can't animate it by accident; `animateFollow` lifts it, and +// `--thread-scroll: none` opts the whole thread back into the native scroll. +const SCROLL_TIMING = [ + "[--thread-scroll:400ms_cubic-bezier(0.2,0.8,0.2,1)]", + "not-data-animate-follow:data-[scrolling=follow]:[--thread-scroll-duration:0ms]!", +]; + +const ThreadRoot = ({ animateFollow = false, className, ...props }: ThreadRootProps) => ( { ); }; +// The playground's scroll-timing settings as the thread's CSS variables. +// "default" sets none, keeping the styled Thread's timing; "native" opts back +// into the browser's smooth scroll. A spring prints its own +// ` linear(…)` pair, and that duration stays its cap under a speed; +// the other easings are capped by the slider's duration. +const toScrollTimingStyle = ( + option: ScrollTimingOption, + duration: number, + speed: number, +): CSSProperties | undefined => { + if (option === "default") return undefined; + if (option === "native") return { "--thread-scroll": "none" } as CSSProperties; + const timing = + option === "spring" ? String(spring(duration / 1000, 0)) : `${duration}ms ${option}`; + return { + "--thread-scroll": timing, + ...(speed > 0 && { "--thread-scroll-speed": String(speed) }), + } as CSSProperties; +}; + // memo: ChatRoot re-renders on every stream chunk and re-creates this element; // with no props the memo always bails, so the Thread chrome (header, overlays, // composer dock) renders only when the session context actually changes. @@ -1082,6 +1103,14 @@ const ChatDefaultLayout = memo(() => { const scrollMode = useSettingsStore((s) => s.scrollMode); const showScrollButton = useSettingsStore((s) => s.showScrollButton); const showOverlays = useSettingsStore((s) => s.showOverlays); + const scrollTiming = useSettingsStore((s) => s.scrollTiming); + const scrollDuration = useSettingsStore((s) => s.scrollDuration); + const scrollSpeed = useSettingsStore((s) => s.scrollSpeed); + const animateFollow = useSettingsStore((s) => s.animateFollow); + const scrollTimingStyle = useMemo( + () => toScrollTimingStyle(scrollTiming, scrollDuration, scrollSpeed), + [scrollTiming, scrollDuration, scrollSpeed], + ); return ( <> @@ -1090,7 +1119,12 @@ const ChatDefaultLayout = memo(() => { following, "follow" lands at the top and follows, "off" disables it. */} {/* A row, so the settings card pushes the thread over instead of covering it. */}
- +
{showOverlays && } diff --git a/components/playground-settings.tsx b/components/playground-settings.tsx index 436f6830..37fd13f1 100644 --- a/components/playground-settings.tsx +++ b/components/playground-settings.tsx @@ -37,7 +37,11 @@ import { } from "@/lib/interface-theme"; import { multipleQuestions, singleQuestion } from "@/lib/playground-demo"; import { usePlaygroundStore } from "@/lib/store/playground"; -import { type CommandSurface, useSettingsStore } from "@/lib/store/settings"; +import { + type CommandSurface, + type ScrollTimingOption, + useSettingsStore, +} from "@/lib/store/settings"; import { cn } from "@/lib/utils"; // Right-aligned compact selects share one footprint across the rows. @@ -69,6 +73,42 @@ const SettingsSection = ({ children }: { children: ReactNode }) => (
{children}
); +// A single-thumb slider sized for a row's control slot. +const CompactSlider = ({ + label, + value, + onValueChange, + min, + max, + step, +}: { + label: string; + value: number; + onValueChange: (value: number) => void; + min: number; + max: number; + step: number; +}) => ( + { + if (typeof next === "number") onValueChange(next); + }} + min={min} + max={max} + step={step} + > + + + + + + + +); + // Inline boolean row. The Settings.Label span carries no id, so the switch // names itself. A command prefix character renders as a keycap badge before // the label ("[@] mentions"). @@ -205,24 +245,14 @@ const ThemeTab = () => { - { - if (typeof value === "number") setSeed("con", value); - }} + onValueChange={(value) => setSeed("con", value)} min={CONTRAST_MIN} max={CONTRAST_MAX} step={CONTRAST_STEP} - > - - - - - - - + />
); @@ -239,6 +269,24 @@ const SCROLL_MODE_OPTIONS: ReadonlyArray<{ value: ThreadAutoScrollMode; label: s { value: "off", label: "Off" }, ]; +const SCROLL_TIMING_OPTIONS: ReadonlyArray<{ value: ScrollTimingOption; label: string }> = [ + { value: "default", label: "Default" }, + { value: "native", label: "Native" }, + { value: "ease-out", label: "Ease out" }, + { value: "ease-in-out", label: "Ease in-out" }, + { value: "linear", label: "Linear" }, + { value: "spring", label: "Spring" }, +]; + +// Up to 5s, so a landing still running when the reply starts streaming is +// easy to watch. +const SCROLL_DURATION_MIN = 100; +const SCROLL_DURATION_MAX = 5000; +const SCROLL_DURATION_STEP = 50; +// 0 is off: the duration stays fixed. +const SCROLL_SPEED_MAX = 5000; +const SCROLL_SPEED_STEP = 250; + const ThreadTab = () => { const scrollMode = useSettingsStore((state) => state.scrollMode); const setScrollMode = useSettingsStore((state) => state.setScrollMode); @@ -246,6 +294,16 @@ const ThreadTab = () => { const setShowScrollButton = useSettingsStore((state) => state.setShowScrollButton); const showOverlays = useSettingsStore((state) => state.showOverlays); const setShowOverlays = useSettingsStore((state) => state.setShowOverlays); + const scrollTiming = useSettingsStore((state) => state.scrollTiming); + const setScrollTiming = useSettingsStore((state) => state.setScrollTiming); + // Default and Native bring their own timing; the sliders shape the rest. + const customTiming = scrollTiming !== "default" && scrollTiming !== "native"; + const scrollDuration = useSettingsStore((state) => state.scrollDuration); + const setScrollDuration = useSettingsStore((state) => state.setScrollDuration); + const scrollSpeed = useSettingsStore((state) => state.scrollSpeed); + const setScrollSpeed = useSettingsStore((state) => state.setScrollSpeed); + const animateFollow = useSettingsStore((state) => state.animateFollow); + const setAnimateFollow = useSettingsStore((state) => state.setAnimateFollow); return (
@@ -270,6 +328,72 @@ const ThreadTab = () => { + + + + {customTiming && ( + <> + 0 + ? `At most ${scrollDuration}ms` + : `${scrollDuration}ms` + } + > + + + 0 ? `${scrollSpeed}px/s` : "Off: fixed duration"} + > + + + + )} + {/* Native has no scripted follow to switch on. */} + {scrollTiming !== "native" && ( + + )} } file="primitives/thread/demos/basic.tsx" /> @@ -135,6 +136,64 @@ screen, and creates its observers only once something subscribes. } file="primitives/thread/demos/jump.tsx" /> +### Timing the scroll + +Smooth scrolls are the browser's own until you give them a timing. Set one on +the root and the thread animates every smooth scroll itself — the landing, the +follow, `scrollToBottom`, `scrollToTop` and `scrollToMessage`: + +```css +[data-thread-root] { + --thread-scroll: 450ms cubic-bezier(0.2, 0.8, 0.2, 1); +} +``` + +The value reads like `transition`: a duration, then any CSS easing, `linear()` +included. That makes a Motion spring a one-liner, since `spring()` prints +exactly that pair: + +```tsx +import { spring } from "motion"; + +{/* … */} +``` + +Keep `bounce` at 0: the scroller can't move past its end, so an overshoot at +the bottom is clipped into a hard stop. + +While an animation runs, the root carries `data-scrolling`, naming why it +started: `land`, `follow`, `bottom`, `top` or `message`. It is set before the +timing is read, so a selector can time each kind on its own — a slow landing +and a brisk follow: + +```css +[data-thread-root] { --thread-scroll: 200ms ease-out; } +[data-thread-root][data-scrolling="land"] { --thread-scroll-duration: 600ms; } +``` + +To pace by distance instead, set `--thread-scroll-speed` in pixels per second; +a duration set alongside it becomes the cap. `--thread-scroll: none` opts a +thread back into the native smooth scroll, over any longhand — useful when a +shared stylesheet sets a timing for every thread. + +**Pin the follow instead of animating it.** A reply grows a line at a time, so +pinning reads as smooth already. Animating the follow keeps a frame loop +running for the whole stream — on the main thread, while it is busiest — and +the easing trails the newest text below the fold: + +```css +[data-thread-root][data-scrolling="follow"] { --thread-scroll-duration: 0ms; } +``` + +The destination is re-read every frame, so a reply that starts streaming before +the landing finishes is carried along by it instead of restarting it. In +`"jump"` the landing's destination is fixed when it starts: a reply outgrowing +the reserve mid-flight never turns the landing into a follow. Any wheel, touch, +scroll key or scrollbar press stops the animation where it is, and under +`prefers-reduced-motion` every scroll is instant. + +} file="primitives/thread/demos/scroll-timing.tsx" /> + ## Why the scroll subsystem is cheap Thread's scroll subsystem is built to stay out of the way while a reply streams: @@ -145,6 +204,11 @@ the work it does is event-driven and small, not per token or per frame. - **Landing and follow are event-driven** — a MutationObserver for new turns, a ResizeObserver for growth, one `scrollTo` per change. No per-token geometry reads, no animation-frame polling. +- **A timed scroll costs one frame loop, only while it runs.** With + `--thread-scroll` set, each smooth scroll reads its timing once and then does + one `scrollTo` per frame until it ends. Unlike the native smooth scroll, it + runs on the main thread, so a frame lost to heavy rendering is a frame the + scroll skips. Leave the timing unset to keep the native scroll. - **Edge state lives in external stores** (one per edge), so a flip re-renders only the components that read it (your scroll button) — never the Thread tree. - **Lazy capabilities cost nothing until used**: visibility tracking creates its @@ -190,6 +254,7 @@ export const rootAttrs = [ { attribute: "data-thread-root", description: "The root element." }, { attribute: "data-at-top", description: "Present while the top edge is in view (start of the transcript) — the CSS-only mirror of useThread().isAtTop." }, { attribute: "data-at-bottom", description: "Present while the bottom edge is in view (at the live end) — the CSS-only mirror of useThread().isAtBottom." }, + { attribute: "data-scrolling", values: '"land" | "follow" | "bottom" | "top" | "message"', description: "Present while a timed scroll animates, naming why it started. Set before the timing is read, so selectors can time each kind. Never set for native or instant scrolls." }, ]; @@ -267,6 +332,10 @@ export const cssVars = [ { attribute: "--thread-width", values: "672px", description: "Max width of the content column and overlays." }, { attribute: "--thread-overlay-top-height", values: "4rem", description: "Top overlay height and top inset." }, { attribute: "--thread-overlay-bottom-height", values: "8rem", description: "Bottom overlay height and bottom inset (measured from the Thread.Composer slot at runtime)." }, + { attribute: "--thread-scroll", values: " | none", description: "Times every smooth scroll. Unset or none keeps the browser's native smooth scroll (none wins over the longhands); 0ms makes them instant. Takes a Motion spring() string as-is." }, + { attribute: "--thread-scroll-duration", values: "", description: "Longhand; overrides the shorthand. The cap when a speed is set." }, + { attribute: "--thread-scroll-easing", values: "", description: "Longhand; overrides the shorthand. Defaults to ease." }, + { attribute: "--thread-scroll-speed", values: "", description: "Pixels per second; the duration follows the distance." }, ]; diff --git a/content/docs/primitives/thread/demos/scroll-timing.tsx b/content/docs/primitives/thread/demos/scroll-timing.tsx new file mode 100644 index 00000000..1bd3d5dd --- /dev/null +++ b/content/docs/primitives/thread/demos/scroll-timing.tsx @@ -0,0 +1,218 @@ +"use client"; + +import { Composer, type ComposerSubmitData } from "@intentface/chat/composer"; +import { Message } from "@intentface/chat/message"; +import { Thread } from "@intentface/chat/thread"; +import { ArrowUp } from "@keyline-icons/react"; +import { spring } from "motion"; +import { type CSSProperties, useEffect, useRef, useState } from "react"; + +type Exchange = { id: string; question: string; answer: string }; + +const SEED: Exchange[] = [ + { + id: "1", + question: "Why does my list re-render on every keystroke?", + answer: + "Because the parent holding the input state re-renders, and every child re-renders with it unless something stops the cascade.", + }, +]; + +// Each preset is plain CSS on the root. Static timings are Tailwind classes; +// the spring is computed by Motion, so it goes through `style`. +const PRESETS = { + native: { className: "", style: {}, caption: "No timing set: the browser's own smooth scroll." }, + snappy: { + className: "[--thread-scroll:250ms_cubic-bezier(0.2,0.8,0.2,1)]", + style: {}, + caption: "A short ease-out for every scroll.", + }, + spring: { + className: "", + // bounce 0: an overshoot past the bottom is clamped by the scroller. + style: { "--thread-scroll": String(spring(0.6, 0)) }, + caption: "Motion's spring(), as a linear() easing.", + }, + paced: { + className: "[--thread-scroll-speed:1200] [--thread-scroll-duration:700ms]", + style: {}, + caption: "1200px/s, capped at 700ms: short scrolls are quick.", + }, + slow: { + className: + "[--thread-scroll:200ms_ease-out] data-[scrolling=land]:[--thread-scroll:5s_ease-in-out]", + style: {}, + caption: "A 5s land: the reply streams in while it is still landing.", + }, +} satisfies Record; + +type Preset = keyof typeof PRESETS; + +const PROMPT = "Is memoising the list enough?"; + +const REPLY = + "Memoisation compares props, so the comparison itself has to be cheaper than the render you are avoiding. That is usually true for a list and rarely true for a single row. Measure first: the profiler shows which renders are worth skipping, and which ones were never the problem. Then memoise the expensive subtree, keep its props stable, and leave the cheap rows alone."; + +/* + * Scroll timing, set from CSS. + * + * With no timing, smooth scrolls are the browser's own. Set `--thread-scroll` + * (or its longhands, or a speed) on the root and the thread animates them + * itself. While it does, the root carries `data-scrolling` naming why — so a + * selector can time the landing differently from the follow, as the slow + * preset does. Scroll by hand at any point and the animation stops. + */ +export const ScrollTiming = () => { + const [preset, setPreset] = useState("spring"); + const [exchanges, setExchanges] = useState(SEED); + const [draft, setDraft] = useState(PROMPT); + const [streaming, setStreaming] = useState(false); + const timers = useRef[]>([]); + + useEffect(() => () => timers.current.forEach(clearTimeout), []); + + const stopStream = () => { + timers.current.forEach(clearTimeout); + timers.current = []; + setStreaming(false); + }; + + const handleSubmit = (data: ComposerSubmitData) => { + if (data.kind !== "message" || !data.text.trim() || streaming) return; + stopStream(); + + const id = `${Date.now()}`; + setExchanges((current) => [...current, { id, question: data.text, answer: "" }]); + setStreaming(true); + + const words = REPLY.split(" "); + words.forEach((_, index) => { + timers.current.push( + setTimeout( + () => { + setExchanges((current) => + current.map((exchange) => + exchange.id === id + ? { ...exchange, answer: words.slice(0, index + 1).join(" ") } + : exchange, + ), + ); + }, + 300 + index * 50, + ), + ); + }); + timers.current.push( + setTimeout( + () => { + setStreaming(false); + setDraft(PROMPT); + }, + 300 + words.length * 50, + ), + ); + }; + + const { className, style, caption } = PRESETS[preset]; + + return ( +
+
+ {(Object.keys(PRESETS) as Preset[]).map((candidate) => ( + + ))} +
+ +
+ + + +
+ + {exchanges.map((exchange, index) => ( + + {/* biome-ignore lint/a11y/useValidAriaRole: `role` is the message's author, not an ARIA role */} + + + {exchange.question} + + + {exchange.answer && ( + // biome-ignore lint/a11y/useValidAriaRole: `role` is the message's author, not an ARIA role + + + {exchange.answer} + + + )} + + ))} + +
+
+ +
+ + + + + + + + + + + + +
+
+
+
+ +

{caption}

+
+ ); +}; + +// The raised surface of the selected preset. +const RAISED = + "bg-white bg-linear-to-b from-white to-[#fdfdfd] shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)] dark:bg-[#2d2d30] dark:from-[#313134] dark:to-[#2a2a2d] dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)]"; diff --git a/lib/store/settings.ts b/lib/store/settings.ts index 04d0f9b9..f0dc2316 100644 --- a/lib/store/settings.ts +++ b/lib/store/settings.ts @@ -23,6 +23,11 @@ type CommandToggles = { // floating Popover anchored to the active command badge. type CommandSurface = "panel" | "popover"; +// The thread's smooth-scroll timing (playground Thread card). "default" keeps +// the styled Thread's own timing, "native" opts back into the browser's smooth +// scroll, and the rest replace the timing with that easing. +type ScrollTimingOption = "default" | "native" | "ease-out" | "ease-in-out" | "linear" | "spring"; + type SettingsStore = { themeOverrides: Record; setThemeOverride: (mode: ThemeMode, key: keyof ThemeOverrides, value: string | number) => void; @@ -30,6 +35,17 @@ type SettingsStore = { resetThemeOverrides: (mode: ThemeMode) => void; scrollMode: ThreadAutoScrollMode; setScrollMode: (mode: ThreadAutoScrollMode) => void; + scrollTiming: ScrollTimingOption; + setScrollTiming: (scrollTiming: ScrollTimingOption) => void; + /** Milliseconds; the cap when a speed is set. */ + scrollDuration: number; + setScrollDuration: (scrollDuration: number) => void; + /** Pixels per second; 0 leaves the duration fixed. */ + scrollSpeed: number; + setScrollSpeed: (scrollSpeed: number) => void; + /** Animate the follow with the scroll timing instead of pinning it. */ + animateFollow: boolean; + setAnimateFollow: (animateFollow: boolean) => void; stickyMessages: boolean; setStickyMessages: (stickyMessages: boolean) => void; showScrollButton: boolean; @@ -52,7 +68,7 @@ type SettingsStore = { setSubmitOn: (submitOn: ComposerSubmitOn) => void; }; -export type { ThemeOverrides, ThemeMode, CommandToggles, CommandSurface }; +export type { ThemeOverrides, ThemeMode, CommandToggles, CommandSurface, ScrollTimingOption }; export const useSettingsStore = create()( persist( @@ -60,6 +76,14 @@ export const useSettingsStore = create()( themeOverrides: { light: {}, dark: {} }, scrollMode: "follow", setScrollMode: (mode) => set({ scrollMode: mode }), + scrollTiming: "default", + setScrollTiming: (scrollTiming) => set({ scrollTiming }), + scrollDuration: 450, + setScrollDuration: (scrollDuration) => set({ scrollDuration }), + scrollSpeed: 0, + setScrollSpeed: (scrollSpeed) => set({ scrollSpeed }), + animateFollow: false, + setAnimateFollow: (animateFollow) => set({ animateFollow }), stickyMessages: false, setStickyMessages: (stickyMessages) => set({ stickyMessages }), showScrollButton: true, diff --git a/packages/ui/src/thread/geometry.ts b/packages/ui/src/thread/geometry.ts index 0f2f4f95..999bb0f7 100644 --- a/packages/ui/src/thread/geometry.ts +++ b/packages/ui/src/thread/geometry.ts @@ -6,10 +6,11 @@ export const prefersReducedMotion = (): boolean => typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; -// "smooth" downgrades to "auto" under prefers-reduced-motion — deliberate -// movements still happen, they just don't animate. +// "smooth" downgrades to "instant" under prefers-reduced-motion — deliberate +// movements still happen, they just don't animate. Not "auto": that defers to +// the scroller's CSS scroll-behavior, which may itself be smooth. export const resolveScrollBehavior = (behavior: ScrollBehavior): ScrollBehavior => - behavior === "smooth" && prefersReducedMotion() ? "auto" : behavior; + behavior === "smooth" && prefersReducedMotion() ? "instant" : behavior; // Single place that performs the scroll, so callers just choose the behavior: // 'instant' for jumps that must not animate, 'smooth' for deliberate movements. diff --git a/packages/ui/src/thread/index.ts b/packages/ui/src/thread/index.ts index 7a5414e2..e33099ee 100644 --- a/packages/ui/src/thread/index.ts +++ b/packages/ui/src/thread/index.ts @@ -10,6 +10,7 @@ export type { ThreadOverlayState, ThreadPlaceholderProps, ThreadRootProps, + ThreadScrollKind, ThreadScrollToMessageOptions, ThreadViewportProps, } from "./thread"; diff --git a/packages/ui/src/thread/scroll-animation.ts b/packages/ui/src/thread/scroll-animation.ts new file mode 100644 index 00000000..cbfd9c7b --- /dev/null +++ b/packages/ui/src/thread/scroll-animation.ts @@ -0,0 +1,184 @@ +// Scripted scroll animation for the thread. The styled layer owns the timing +// through CSS variables on the thread root; the thread owns the motion. When no +// timing is configured, callers fall back to the browser's native smooth scroll. + +// Why a scroll was started. Stamped on the root as data-scrolling *before* the +// timing is read, so a selector can give each kind its own timing. +export type ThreadScrollKind = "land" | "follow" | "bottom" | "top" | "message"; + +// What the CSS variables configure. The duration itself is resolved per +// scroll, since a speed makes it depend on the distance. +export type ScrollTiming = { + /** The fixed duration (ms); the cap when a speed is set. */ + duration: number | null; + /** Pixels per second; null when the duration is fixed. */ + speed: number | null; + easing: string; +}; + +const DEFAULT_EASING = "ease"; + +// A CSS