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 (
@@ -368,8 +383,8 @@ export function PixelLoader(props: PixelLoaderProps) {
...(isPlaying &&
!isReducedMotion && {
animation:
- `${animId}-${i}-y ${duration}ms linear ${iteration}, ` +
- `${animId}-${i}-s ${duration}ms linear ${iteration}`,
+ `${animId}-${i}-y ${duration}ms linear ${iteration} ${isInView ? 'running' : 'paused'}, ` +
+ `${animId}-${i}-s ${duration}ms linear ${iteration} ${isInView ? 'running' : 'paused'}`,
willChange: 'transform'
})
}}
diff --git a/packages/dev/s2-docs/pages/s2/ai-components.mdx b/packages/dev/s2-docs/pages/s2/ai-components.mdx
index fc84cc7c83d..9b01eb0143d 100644
--- a/packages/dev/s2-docs/pages/s2/ai-components.mdx
+++ b/packages/dev/s2-docs/pages/s2/ai-components.mdx
@@ -585,37 +585,16 @@ The `icon` prop accepts either a single icon, which loops forever, or a themed p
"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(() => {
- if (!ref.current) {
- return;
- }
-
- let observer = new IntersectionObserver(([entry]) => {
- setIsInView(entry.isIntersecting);
- });
- observer.observe(ref.current);
- return () => observer.disconnect();
- }, []);
-
return (
-
- {isInView && (
- <>
- {/*- begin highlight -*/}
-
- {/*- end highlight -*/}
- >
- )
- }
+
+ {/*- begin highlight -*/}
+
+ {/*- end highlight -*/}
);
}
@@ -632,34 +611,15 @@ Use the `pixelLoader` prop on `ResponseStatusTitle` and `PromptFieldToken` to di
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(() => {
- if (!ref.current){
- return;
- }
-
- let observer = new IntersectionObserver(
- ([entry]) => setIsInView(entry.isIntersecting)
- );
- observer.observe(ref.current);
- return () => observer.disconnect();
- }, []);
-
return (
-
- {isInView && (
-
- {/*- begin highlight -*/}
- Analyzing campaign data...
- {/*- end highlight -*/}
-
- )
- }
+
+
+ {/*- begin highlight -*/}
+ Analyzing campaign data...
+ {/*- end highlight -*/}
+
);
}
From 891c8e12bbb4978fb0e03f99b3dedbd745f50dd9 Mon Sep 17 00:00:00 2001
From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com>
Date: Fri, 4 Sep 2026 16:35:10 -0700
Subject: [PATCH 7/8] small docs stuff
---
packages/dev/s2-docs/pages/s2/ai-components.mdx | 4 ++--
1 file changed, 2 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 9b01eb0143d..e0227ae2745 100644
--- a/packages/dev/s2-docs/pages/s2/ai-components.mdx
+++ b/packages/dev/s2-docs/pages/s2/ai-components.mdx
@@ -614,8 +614,8 @@ import {style} from '@react-spectrum/s2/style' with {type: 'macro'};
function LoadingStatus() {
return (
-
-
+
+
{/*- begin highlight -*/}
Analyzing campaign data...
{/*- end highlight -*/}
From 1f84d8d2f4c4f0ef84718a808ef5bb360420094b Mon Sep 17 00:00:00 2001
From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com>
Date: Fri, 4 Sep 2026 17:00:42 -0700
Subject: [PATCH 8/8] Apply suggestion from @yihuiliao
---
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 e0227ae2745..d6ac0b1e0c4 100644
--- a/packages/dev/s2-docs/pages/s2/ai-components.mdx
+++ b/packages/dev/s2-docs/pages/s2/ai-components.mdx
@@ -602,7 +602,7 @@ function PresetLoader(props) {
### Usage
-Use the `pixelLoader` prop on `ResponseStatusTitle` and `PromptFieldToken` to display the pixel loader.
+Use the `pixelLoader` prop on `ResponseStatusTitle` and `PromptTokenField` to display the pixel loader.