From b1807a669d6c2025a7b3469b165f1eef6e197cfd Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:12:26 -0700 Subject: [PATCH 1/8] chore: don't animate pixel loader when not in view in docs --- .../dev/s2-docs/pages/s2/ai-components.mdx | 40 ++++++++++++++++--- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/packages/dev/s2-docs/pages/s2/ai-components.mdx b/packages/dev/s2-docs/pages/s2/ai-components.mdx index 9a3b1ee27b2..826100b8bfb 100644 --- a/packages/dev/s2-docs/pages/s2/ai-components.mdx +++ b/packages/dev/s2-docs/pages/s2/ai-components.mdx @@ -581,39 +581,67 @@ function BasicChat() { The `icon` prop accepts either a single icon, which loops forever, or a themed preset, which cycles through several icons, one per loop. Icons and presets are exported individually from `@react-spectrum/ai/loader`. -```tsx render type="s2" docs={docs.exports.PixelLoader} props={['icon', 'isPlaying']} initialProps={{isPlaying: true}} +```tsx render type="s2" docs={docs.exports.PixelLoader} props={['icon']} "use client"; import {PixelLoader} from '@react-spectrum/ai/loader'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {useRef, useState, useEffect} from 'react'; function PresetLoader(props) { + let ref = useRef(null); + let [isInView, setIsInView] = useState(false); + + useEffect(() => { + let observer = new IntersectionObserver(([entry]) => { + setIsInView(entry.isIntersecting); + }); + observer.observe(ref.current); + return () => observer.disconnect(); + }, []); + return ( -
+
+ {/*- begin highlight -*/} + {/*- end highlight -*/}
- ) + ); } ``` ### Usage -Use the `pixelLoader` prop on `PromptTokenField` and `ResponseStatusTitle` to display the pixel loader. +Use the `pixelLoader` prop on `ResponseStatusTitle` and `PromptFieldToken` to display the pixel loader. + ```tsx render type="s2" "use client"; import {cc} from '@react-spectrum/ai/loader'; import {ResponseStatus, ResponseStatusTitle} from '@react-spectrum/ai'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {useRef, useState, useEffect} from 'react'; function LoadingStatus() { + let ref = useRef(null); + let [isInView, setIsInView] = useState(false); + + useEffect(() => { + let observer = new IntersectionObserver( + ([entry]) => setIsInView(entry.isIntersecting) + ); + observer.observe(ref.current); + return () => observer.disconnect(); + }, []); + return ( -
- +
+ {/*- begin highlight -*/} Analyzing campaign data... {/*- end highlight -*/} From dde84bffc3377ef721bdf2edbec52ba173d8ca11 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:22:00 -0700 Subject: [PATCH 2/8] update render --- .../dev/s2-docs/pages/s2/ai-components.mdx | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/packages/dev/s2-docs/pages/s2/ai-components.mdx b/packages/dev/s2-docs/pages/s2/ai-components.mdx index 826100b8bfb..54b4e66b636 100644 --- a/packages/dev/s2-docs/pages/s2/ai-components.mdx +++ b/packages/dev/s2-docs/pages/s2/ai-components.mdx @@ -581,7 +581,7 @@ function BasicChat() { The `icon` prop accepts either a single icon, which loops forever, or a themed preset, which cycles through several icons, one per loop. Icons and presets are exported individually from `@react-spectrum/ai/loader`. -```tsx render type="s2" docs={docs.exports.PixelLoader} props={['icon']} +```tsx render type="s2" docs={docs.exports.PixelLoader} props={['icon', 'isPlaying']} initialProps={{isPlaying: true}} "use client"; import {PixelLoader} from '@react-spectrum/ai/loader'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; @@ -601,13 +601,17 @@ function PresetLoader(props) { return (
- {/*- begin highlight -*/} - - {/*- end highlight -*/} + {isInView && ( + <> + {/*- begin highlight -*/} + + {/*- end highlight -*/} + + ) + }
); } @@ -641,11 +645,15 @@ function LoadingStatus() { return (
- - {/*- begin highlight -*/} - Analyzing campaign data... - {/*- end highlight -*/} - + { + isInView && ( + + {/*- begin highlight -*/} + Analyzing campaign data... + {/*- end highlight -*/} + + ) + }
); } From 1232a45c7c7dfe6a97b2d2af0b484c488b17f837 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:23:05 -0700 Subject: [PATCH 3/8] formatting --- packages/dev/s2-docs/pages/s2/ai-components.mdx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/dev/s2-docs/pages/s2/ai-components.mdx b/packages/dev/s2-docs/pages/s2/ai-components.mdx index 54b4e66b636..1a175c8d494 100644 --- a/packages/dev/s2-docs/pages/s2/ai-components.mdx +++ b/packages/dev/s2-docs/pages/s2/ai-components.mdx @@ -623,7 +623,6 @@ Use the `pixelLoader` prop on `ResponseStatusTitle` and `PromptFieldToken` to di - ```tsx render type="s2" "use client"; import {cc} from '@react-spectrum/ai/loader'; @@ -645,8 +644,7 @@ function LoadingStatus() { return (
- { - isInView && ( + {isInView && ( {/*- begin highlight -*/} Analyzing campaign data... From 51c5800f793f7b96f61115246a58c25665540c32 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:27:43 -0700 Subject: [PATCH 4/8] fix typecheck --- packages/dev/s2-docs/pages/s2/ai-components.mdx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/dev/s2-docs/pages/s2/ai-components.mdx b/packages/dev/s2-docs/pages/s2/ai-components.mdx index 1a175c8d494..896a6ed2a4c 100644 --- a/packages/dev/s2-docs/pages/s2/ai-components.mdx +++ b/packages/dev/s2-docs/pages/s2/ai-components.mdx @@ -588,10 +588,14 @@ import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {useRef, useState, useEffect} from 'react'; function PresetLoader(props) { - let ref = useRef(null); + let ref = useRef(null); let [isInView, setIsInView] = useState(false); useEffect(() => { + let (!ref.current) { + return; + } + let observer = new IntersectionObserver(([entry]) => { setIsInView(entry.isIntersecting); }); @@ -631,10 +635,14 @@ import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {useRef, useState, useEffect} from 'react'; function LoadingStatus() { - let ref = useRef(null); + let ref = useRef(null); let [isInView, setIsInView] = useState(false); useEffect(() => { + if (!ref.current){ + return; + } + let observer = new IntersectionObserver( ([entry]) => setIsInView(entry.isIntersecting) ); From 96886049cfc0099dfbfd5056eabfa56d6ab1316f Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:32:08 -0700 Subject: [PATCH 5/8] fix typecheck fr --- packages/dev/s2-docs/pages/s2/ai-components.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dev/s2-docs/pages/s2/ai-components.mdx b/packages/dev/s2-docs/pages/s2/ai-components.mdx index 896a6ed2a4c..fc84cc7c83d 100644 --- a/packages/dev/s2-docs/pages/s2/ai-components.mdx +++ b/packages/dev/s2-docs/pages/s2/ai-components.mdx @@ -592,7 +592,7 @@ function PresetLoader(props) { let [isInView, setIsInView] = useState(false); useEffect(() => { - let (!ref.current) { + if (!ref.current) { return; } From 67add4c851d1254b695311fcfbc50c14062cd906 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:34:19 -0700 Subject: [PATCH 6/8] add intersection observer to pixel loader instead --- .../@react-spectrum/ai/src/loader/react.tsx | 25 +++++-- .../dev/s2-docs/pages/s2/ai-components.mdx | 66 ++++--------------- 2 files changed, 33 insertions(+), 58 deletions(-) diff --git a/packages/@react-spectrum/ai/src/loader/react.tsx b/packages/@react-spectrum/ai/src/loader/react.tsx index 05ae431e1e9..877f69b2b6b 100644 --- a/packages/@react-spectrum/ai/src/loader/react.tsx +++ b/packages/@react-spectrum/ai/src/loader/react.tsx @@ -260,6 +260,19 @@ export function PixelLoader(props: PixelLoaderProps) { } = props; let isReducedMotion = useReducedMotion(); + let ref = React.useRef(null); + let [isInView, setIsInView] = React.useState(false); + + React.useEffect(() => { + if (!ref.current) { + return; + } + + let observer = new IntersectionObserver(([entry]) => setIsInView(entry.isIntersecting)); + observer.observe(ref.current); + return () => observer.disconnect(); + }, []); + // Normalize to a sequence. const sequence = React.useMemo( () => (Array.isArray(icon[0]) ? icon : [icon]) as Cell[][], @@ -284,13 +297,14 @@ export function PixelLoader(props: PixelLoaderProps) { // Advance the sequence one icon per cycle while playing. Single-icon // loaders never start a timer — they're a pure infinite CSS loop. + let isAdvancing = isPlaying && isInView; React.useEffect(() => { - if (!isPlaying || !isSequence) { + if (!isAdvancing || !isSequence) { return undefined; } const id = setInterval(() => setTick(t => t + 1), duration); return () => clearInterval(id); - }, [isPlaying, isSequence, duration, sequence]); + }, [isAdvancing, isSequence, duration, sequence]); const animId = iconId(cells); const css = keyframesFor(cells, isReducedMotion); @@ -314,6 +328,7 @@ export function PixelLoader(props: PixelLoaderProps) { return (