Skip to content

Commit ff300df

Browse files
authored
feat(Toast): use View Transition API for Toast animations (#7631)
* remove animation code from toast hooks * add View Transitions * lint * fix invalid viewTransitionName * fix Multiple story * fix transition when programmatically closing toast * fix slide in/out * fix ts-ignore * update yarn.lock * memoize placement * add placement to fullscreen story * fade out toasts that are centered, and not the last one * lint * add wrapUpdate option to ToastQueue * update where runWithWrapUpdate gets called * fix function param
1 parent 0a3204c commit ff300df

File tree

13 files changed

+147
-324
lines changed

13 files changed

+147
-324
lines changed

packages/@react-aria/toast/docs/useToast.mdx

Lines changed: 0 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ There is no built in way to toast notifications in HTML. <TypeLink links={docs.l
5050
* **Accessible** – Toasts follow the [ARIA alert pattern](https://www.w3.org/WAI/ARIA/apg/patterns/alert/). They are rendered in a [landmark region](https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions/), which keyboard and screen reader users can easily jump to when an alert is announced.
5151
* **Focus management** – When a toast unmounts, focus is moved to the next toast if any. Otherwise, focus is restored to where it was before navigating to the toast region.
5252
* **Priority queue** – Toasts are displayed according to a priority queue, displaying a configurable number of toasts at a time. The queue can either be owned by a provider component, or global.
53-
* **Animations** – Toasts support optional entry and exit animations.
5453

5554
## Anatomy
5655

@@ -346,71 +345,6 @@ Now you can queue a toast from anywhere:
346345
<Button onPress={() => toastQueue.add('Toast is done!')}>Show toast</Button>
347346
```
348347

349-
### Animations
350-
351-
`useToastState` and `ToastQueue` support a `hasExitAnimation` option. When enabled, toasts transition to an "exiting" state when closed rather than immediately being removed. This allows you to trigger an exit animation. When complete, call the `state.remove` function.
352-
353-
Each <TypeLink links={statelyDocs.links} type={statelyDocs.exports.QueuedToast} /> includes an `animation` property that indicates the current animation state. There are three possible states:
354-
355-
* `entering` – The toast is entering immediately after being triggered.
356-
* `queued` – The toast is entering from the queue (out of view).
357-
* `exiting` – The toast is exiting from view.
358-
359-
```tsx
360-
function ToastRegion() {
361-
let state = useToastState({
362-
maxVisibleToasts: 5,
363-
/*- begin highlight -*/
364-
hasExitAnimation: true
365-
/*- end highlight -*/
366-
});
367-
368-
// ...
369-
}
370-
371-
function Toast({state, ...props}) {
372-
let ref = React.useRef(null);
373-
let {toastProps, titleProps, closeButtonProps} = useToast(props, state, ref);
374-
375-
return (
376-
<div
377-
{...toastProps}
378-
ref={ref}
379-
className="toast"
380-
/*- begin highlight -*/
381-
// Use a data attribute to trigger animations in CSS.
382-
data-animation={props.toast.animation}
383-
onAnimationEnd={() => {
384-
// Remove the toast when the exiting animation completes.
385-
if (props.toast.animation === 'exiting') {
386-
state.remove(props.toast.key);
387-
}
388-
}}
389-
/*- end highlight -*/
390-
>
391-
<div {...titleProps}>{props.toast.content}</div>
392-
<Button {...closeButtonProps}>x</Button>
393-
</div>
394-
);
395-
}
396-
```
397-
398-
In CSS, the data attribute defined above can be used to trigger keyframe animations:
399-
400-
```css
401-
.toast[data-animation=entering] {
402-
animation-name: slide-in;
403-
}
404-
405-
.toast[data-animation=queued] {
406-
animation-name: fade-in;
407-
}
408-
409-
.toast[data-animation=exiting] {
410-
animation-name: slide-out;
411-
}
412-
```
413-
414348
### TypeScript
415349

416350
A `ToastQueue` and `useToastState` use a generic type to represent toast content. The examples so far have used strings, but you can type this however you want to enable passing custom objects or options. This example uses a custom object to support toasts with both a title and description.

packages/@react-aria/toast/src/useToast.ts

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {AriaLabelingProps, DOMAttributes, FocusableElement, RefObject} from '@re
1515
// @ts-ignore
1616
import intlMessages from '../intl/*.json';
1717
import {QueuedToast, ToastState} from '@react-stately/toast';
18-
import React, {useEffect} from 'react';
18+
import {useEffect} from 'react';
1919
import {useId, useSlotId} from '@react-aria/utils';
2020
import {useLocalizedStringFormatter} from '@react-aria/i18n';
2121

@@ -46,8 +46,7 @@ export function useToast<T>(props: AriaToastProps<T>, state: ToastState<T>, ref:
4646
let {
4747
key,
4848
timer,
49-
timeout,
50-
animation
49+
timeout
5150
} = props.toast;
5251

5352
useEffect(() => {
@@ -61,13 +60,6 @@ export function useToast<T>(props: AriaToastProps<T>, state: ToastState<T>, ref:
6160
};
6261
}, [timer, timeout]);
6362

64-
let [isEntered, setIsEntered] = React.useState(false);
65-
useEffect(() => {
66-
if (animation === 'entering' || animation === 'queued') {
67-
setIsEntered(true);
68-
}
69-
}, [animation]);
70-
7163
let titleId = useId();
7264
let descriptionId = useSlotId();
7365
let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/toast');
@@ -80,16 +72,11 @@ export function useToast<T>(props: AriaToastProps<T>, state: ToastState<T>, ref:
8072
'aria-labelledby': props['aria-labelledby'] || titleId,
8173
'aria-describedby': props['aria-describedby'] || descriptionId,
8274
'aria-details': props['aria-details'],
83-
// Hide toasts that are animating out so VoiceOver doesn't announce them.
84-
'aria-hidden': animation === 'exiting' ? 'true' : undefined,
8575
tabIndex: 0
8676
},
8777
contentProps: {
8878
role: 'alert',
89-
'aria-atomic': 'true',
90-
style: {
91-
visibility: isEntered || animation === null ? 'visible' : 'hidden'
92-
}
79+
'aria-atomic': 'true'
9380
},
9481
titleProps: {
9582
id: titleId

packages/@react-aria/toast/src/useToastRegion.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1+
/*
2+
* Copyright 2025 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
113
import {AriaLabelingProps, DOMAttributes, FocusableElement, RefObject} from '@react-types/shared';
214
import {focusWithoutScrolling, mergeProps, useLayoutEffect} from '@react-aria/utils';
315
import {getInteractionModality, useFocusWithin, useHover} from '@react-aria/interactions';

packages/@react-aria/toast/test/useToast.test.js

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {act, fireEvent, pointerMap, render, renderHook, within} from '@react-spectrum/test-utils-internal';
13+
import {act, pointerMap, render, renderHook, within} from '@react-spectrum/test-utils-internal';
1414
import {composeStories} from '@storybook/react';
1515
import React, {useRef} from 'react';
1616
import * as stories from '../stories/useToast.stories';
@@ -51,12 +51,6 @@ describe('useToast', () => {
5151
});
5252

5353
describe('single toast at a time', () => {
54-
function fireAnimationEnd(alert) {
55-
let e = new Event('animationend', {bubbles: true, cancelable: false});
56-
e.animationName = 'fade-out';
57-
fireEvent(alert, e);
58-
}
59-
6054
let user;
6155
beforeAll(() => {
6256
user = userEvent.setup({delay: null, pointerMap});
@@ -82,15 +76,13 @@ describe('single toast at a time', () => {
8276
expect(toast.textContent).toContain('High');
8377
let closeButton = within(toast).getByRole('button');
8478
await user.click(closeButton);
85-
fireAnimationEnd(toast);
8679

8780
toast = tree.getByRole('alertdialog');
8881
expect(toast.textContent).toContain('Low');
8982
expect(toast).toHaveFocus();
9083

9184
closeButton = within(toast).getByRole('button');
9285
await user.click(closeButton);
93-
fireAnimationEnd(toast);
9486

9587
expect(tree.queryByRole('alertdialog')).toBeNull();
9688
expect(bLow).toHaveFocus();

packages/@react-spectrum/toast/chromatic/Toast.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {useToastState} from '@react-stately/toast';
1616

1717
function FakeToast(props) {
1818
let state = useToastState<any>();
19-
return <Toast toast={{content: props, key: 'toast', animation: 'entering'}} state={state} />;
19+
return <Toast toast={{content: props, key: 'toast'}} state={state} />;
2020
}
2121

2222
export default {

packages/@react-spectrum/toast/src/Toast.tsx

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ export const Toast = React.forwardRef(function Toast(props: SpectrumToastProps,
5252
let {
5353
toast: {
5454
key,
55-
animation,
5655
content: {
5756
children,
5857
variant,
@@ -107,12 +106,6 @@ export const Toast = React.forwardRef(function Toast(props: SpectrumToastProps,
107106
style={{
108107
...styleProps.style,
109108
zIndex: props.toast.priority
110-
}}
111-
data-animation={animation}
112-
onAnimationEnd={() => {
113-
if (animation === 'exiting') {
114-
state.remove(key);
115-
}
116109
}}>
117110
<div
118111
{...contentProps}

packages/@react-spectrum/toast/src/ToastContainer.tsx

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ import {AriaToastRegionProps} from '@react-aria/toast';
1414
import {classNames} from '@react-spectrum/utils';
1515
import {DOMProps} from '@react-types/shared';
1616
import {filterDOMProps} from '@react-aria/utils';
17-
import React, {ReactElement, useEffect, useRef} from 'react';
17+
import {flushSync} from 'react-dom';
18+
import React, {ReactElement, useEffect, useMemo, useRef} from 'react';
1819
import {SpectrumToastValue, Toast} from './Toast';
1920
import toastContainerStyles from './toastContainer.css';
2021
import {Toaster} from './Toaster';
@@ -38,13 +39,29 @@ export interface SpectrumToastOptions extends Omit<ToastOptions, 'priority'>, DO
3839

3940
type CloseFunction = () => void;
4041

42+
function wrapInViewTransition<R>(fn: () => R): R {
43+
if ('startViewTransition' in document) {
44+
let result: R;
45+
// @ts-expect-error
46+
document.startViewTransition(() => {
47+
flushSync(() => {
48+
result = fn();
49+
});
50+
});
51+
// @ts-ignore
52+
return result;
53+
} else {
54+
return fn();
55+
}
56+
}
57+
4158
// There is a single global toast queue instance for the whole app, initialized lazily.
4259
let globalToastQueue: ToastQueue<SpectrumToastValue> | null = null;
4360
function getGlobalToastQueue() {
4461
if (!globalToastQueue) {
4562
globalToastQueue = new ToastQueue({
4663
maxVisibleToasts: Infinity,
47-
hasExitAnimation: true
64+
wrapUpdate: wrapInViewTransition
4865
});
4966
}
5067

@@ -94,12 +111,6 @@ export function ToastContainer(props: SpectrumToastContainerProps): ReactElement
94111
triggerSubscriptions();
95112

96113
return () => {
97-
// When this toast provider unmounts, reset all animations so that
98-
// when the new toast provider renders, it is seamless.
99-
for (let toast of getGlobalToastQueue().visibleToasts) {
100-
toast.animation = null;
101-
}
102-
103114
// Remove this toast provider, and call subscriptions.
104115
// This will cause all other instances to re-render,
105116
// and the first one to become the new active toast provider.
@@ -112,19 +123,39 @@ export function ToastContainer(props: SpectrumToastContainerProps): ReactElement
112123
let activeToastContainer = useActiveToastContainer();
113124
let state = useToastQueue(getGlobalToastQueue());
114125

126+
let {placement, isCentered} = useMemo(() => {
127+
let placements = (props.placement ?? 'bottom').split(' ');
128+
let placement = placements[placements.length - 1];
129+
let isCentered = placements.length === 1;
130+
return {placement, isCentered};
131+
}, [props.placement]);
132+
115133
if (ref === activeToastContainer && state.visibleToasts.length > 0) {
116134
return (
117135
<Toaster state={state} {...props}>
118136
<ol className={classNames(toastContainerStyles, 'spectrum-ToastContainer-list')}>
119-
{state.visibleToasts.slice().reverse().map((toast) => (
120-
<li
121-
key={toast.key}
122-
className={classNames(toastContainerStyles, 'spectrum-ToastContainer-listitem')}>
123-
<Toast
124-
toast={toast}
125-
state={state} />
126-
</li>
127-
))}
137+
{state.visibleToasts.slice().reverse().map((toast, index) => {
138+
let shouldFade = isCentered && index !== 0;
139+
return (
140+
<li
141+
key={toast.key}
142+
className={classNames(toastContainerStyles, 'spectrum-ToastContainer-listitem')}
143+
style={{
144+
// @ts-expect-error
145+
viewTransitionName: `_${toast.key.slice(2)}`,
146+
viewTransitionClass: classNames(
147+
toastContainerStyles,
148+
'toast',
149+
placement,
150+
{'fadeOnly': shouldFade}
151+
)
152+
}}>
153+
<Toast
154+
toast={toast}
155+
state={state} />
156+
</li>
157+
);
158+
})}
128159
</ol>
129160
</Toaster>
130161
);

0 commit comments

Comments
 (0)