diff --git a/.changeset/connected-accounts-verification-error-no-animate.md b/.changeset/connected-accounts-verification-error-no-animate.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/connected-accounts-verification-error-no-animate.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md
index 0ef6ef8a5da..742f7698028 100644
--- a/packages/mosaic/AGENTS.md
+++ b/packages/mosaic/AGENTS.md
@@ -50,7 +50,7 @@ Most problems a feature hits already have a shared answer. Find the row for your
| A value turned into kebab case, e.g. for a `data-*` attribute | `kebabCase` (`src/utils/kebab-case.ts`) |
| Current time for display labels | `useNow` (`src/hooks/`) |
| Whether enterprise SSO permits adding another identification | `allowsIdentificationCreation` (`src/utils/allows-identification-creation.ts`) |
-| An inline message row that animates open and closed | `useHeldMessage`, `useMessageHeight`, `FeedbackBody` (`src/utils/feedback.tsx`) + `src/styles/feedback.styles.ts` |
+| An inline message row; height is undefined until measured | `useHeldMessage`, `useMessageHeight`, `FeedbackBody` (`src/utils/feedback.tsx`) + `src/styles/feedback.styles.ts` |
| Data read from a Clerk resource that should be cached across mounts | A query hook in `src/queries/`, called from the model (`src/queries/use-user-sessions-query.ts`) |
| An item changed in place in an infinite list, kept until the next reload | `replaceInPages` (`src/utils/infinite-pages.ts`), passed to the list's `setData` |
| Siblings that slide when an item is added or removed | `useLayoutAnimation` (`src/primitives/hooks/`) |
diff --git a/packages/mosaic/docs/headless.md b/packages/mosaic/docs/headless.md
index 1566e746658..3a4e5685209 100644
--- a/packages/mosaic/docs/headless.md
+++ b/packages/mosaic/docs/headless.md
@@ -75,10 +75,12 @@ All enter/exit timing lives in **CSS**; the primitives only toggle `data-*`
attributes and drive unmount off the Web Animations API. Root spreads
`transitionProps` (from `useTransition`) onto the Popup; the lifecycle:
-- **Open** → synchronously `mounted=true`, status `'starting'`: first committed
- frame carries `data-open` + `data-starting-style` + inline `transition: none`
- (snapshot frame). One rAF later `data-starting-style` clears → CSS transitions
- fire toward the resting style.
+- **Initially open** → `mounted=true`, with `data-open` and no starting style by
+ default. Set `animateOnMount: true` to start an entrance on mount.
+- **Open after mount** → synchronously `mounted=true`, status `'starting'`: first
+ committed frame carries `data-open` + `data-starting-style` + inline
+ `transition: none` (snapshot frame). One rAF later `data-starting-style`
+ clears → CSS transitions fire toward the resting style.
- **Close** → status `'ending'`: `data-closed` + `data-ending-style` applied.
After all animations on the element finish (`useAnimationsFinished`),
`mounted` flips false and the element unmounts.
diff --git a/packages/mosaic/docs/motion.md b/packages/mosaic/docs/motion.md
index 27e9f966960..01607c0f186 100644
--- a/packages/mosaic/docs/motion.md
+++ b/packages/mosaic/docs/motion.md
@@ -231,6 +231,12 @@ over: the wrapper is its own clip layer (no padding, `align-content: end`), the
message leaves the flow pinned to the **bottom** (`inset: auto 0 0`) so it rides the
moving edge, and the mask is the same static top fade.
+`useMessageHeight` returns `undefined` until the first measurement.
+`feedbackHeight.measured` leaves the height variable unset in that state so an
+initially visible row uses natural layout instead of transitioning from a false
+zero height. Once measured, the hook retains the last numeric height, including
+zero, while the content exits.
+
The spacing above a message is the parent's gap, which a closed row must not cost.
Cancel the parent's gap on the wrapper and put it back inside the clip as the
message's margin: one constant that is also the fade's length (`GAP` in
diff --git a/packages/mosaic/src/components/__tests__/initial-transition.browser.test.tsx b/packages/mosaic/src/components/__tests__/initial-transition.browser.test.tsx
new file mode 100644
index 00000000000..89d7070ebce
--- /dev/null
+++ b/packages/mosaic/src/components/__tests__/initial-transition.browser.test.tsx
@@ -0,0 +1,121 @@
+import { cleanup, render, screen, waitFor } from '@testing-library/react';
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { Card } from '../card';
+import { Field } from '../field';
+import { Section } from '../section';
+
+afterEach(() => cleanup());
+
+const cases = [
+ {
+ name: 'section error',
+ property: 'height',
+ renderMessage: (message: string) => {message},
+ },
+ {
+ name: 'card banner',
+ property: 'grid-template-rows',
+ renderMessage: (message: string) => {message},
+ },
+];
+
+describe.each(cases)('$name motion', ({ property, renderMessage }) => {
+ it('shows an initial message without animating its layout', async () => {
+ render(renderMessage('Something went wrong.'));
+ const message = screen.getByRole('alert');
+
+ expect(getComputedStyle(message).transitionProperty).toBe(property);
+ expect(getComputedStyle(message).transitionDuration).not.toBe('0s');
+ expect(message.getBoundingClientRect().height).toBeGreaterThan(0);
+ expect(
+ message
+ .getAnimations({ subtree: true })
+ .map(animation =>
+ animation instanceof CSSTransition ? animation.transitionProperty : animation.constructor.name,
+ ),
+ ).toEqual([]);
+
+ await new Promise(resolve => requestAnimationFrame(() => resolve()));
+ await new Promise(resolve => requestAnimationFrame(() => resolve()));
+
+ expect(message.getAnimations({ subtree: true })).toHaveLength(0);
+ });
+
+ it('animates a message that arrives after mount', async () => {
+ const { rerender } = render(renderMessage(''));
+
+ await new Promise(resolve => requestAnimationFrame(() => resolve()));
+ rerender(renderMessage('Something went wrong.'));
+ const message = screen.getByRole('alert');
+ expect(message.getBoundingClientRect().height).toBe(0);
+
+ await new Promise(resolve => requestAnimationFrame(() => resolve()));
+ await new Promise(resolve => requestAnimationFrame(() => resolve()));
+
+ expect(
+ message
+ .getAnimations()
+ .some(animation => animation instanceof CSSTransition && animation.transitionProperty === property),
+ ).toBe(true);
+ });
+});
+
+it('animates field feedback opening, resizing, closing, and reopening', async () => {
+ const renderMessage = (text: string) => (
+
+
+ {text}
+
+
+ );
+ const { rerender } = render(renderMessage(''));
+ const message = screen.getByRole('status');
+ expect(message.getBoundingClientRect().height).toBe(0);
+ await new Promise(resolve => requestAnimationFrame(() => resolve()));
+
+ rerender(renderMessage('Invalid email.'));
+ await waitFor(() =>
+ expect(
+ message
+ .getAnimations()
+ .some(animation => animation instanceof CSSTransition && animation.transitionProperty === 'height'),
+ ).toBe(true),
+ );
+ await waitFor(() => expect(message.getAnimations({ subtree: true })).toHaveLength(0));
+ const shortHeight = message.getBoundingClientRect().height;
+ expect(shortHeight).toBeGreaterThan(0);
+
+ rerender(renderMessage('This email address is invalid. Enter a complete email address before continuing.'));
+ await waitFor(() =>
+ expect(
+ message
+ .getAnimations()
+ .some(animation => animation instanceof CSSTransition && animation.transitionProperty === 'height'),
+ ).toBe(true),
+ );
+ await waitFor(() => expect(message.getAnimations({ subtree: true })).toHaveLength(0));
+ expect(message.getBoundingClientRect().height).toBeGreaterThan(shortHeight);
+
+ rerender(renderMessage(''));
+ await waitFor(() =>
+ expect(
+ message
+ .getAnimations()
+ .some(animation => animation instanceof CSSTransition && animation.transitionProperty === 'height'),
+ ).toBe(true),
+ );
+ await waitFor(() => expect(message.getBoundingClientRect().height).toBe(0));
+ await waitFor(() => expect(screen.queryByText(/This email address is invalid/)).toBeNull());
+
+ rerender(renderMessage('Invalid email.'));
+ await waitFor(() =>
+ expect(
+ message
+ .getAnimations()
+ .some(animation => animation instanceof CSSTransition && animation.transitionProperty === 'height'),
+ ).toBe(true),
+ );
+ await waitFor(() => expect(message.getAnimations({ subtree: true })).toHaveLength(0));
+ expect(message.getBoundingClientRect().height).toBe(shortHeight);
+});
diff --git a/packages/mosaic/src/components/action-bar/action-bar.test.tsx b/packages/mosaic/src/components/action-bar/action-bar.test.tsx
index caed024cd9f..d85a0955249 100644
--- a/packages/mosaic/src/components/action-bar/action-bar.test.tsx
+++ b/packages/mosaic/src/components/action-bar/action-bar.test.tsx
@@ -71,6 +71,8 @@ describe('Mosaic ActionBar', () => {
const bar = screen.getByRole('toolbar', { name: 'Bulk actions' });
expect(bar).toHaveClass('cl-action-bar');
expect(bar).toHaveAttribute('data-open', 'true');
+ expect(bar).not.toHaveAttribute('data-starting-style');
+ expect(bar.style.transition).toBe('');
expect(bar).toHaveAttribute('aria-controls', 'members');
expect(bar).toHaveAttribute('aria-orientation', 'horizontal');
expect(bar).toHaveAccessibleDescription('3 selected');
diff --git a/packages/mosaic/src/components/action-bar/action-bar.tsx b/packages/mosaic/src/components/action-bar/action-bar.tsx
index 7198b4fd0ab..2d97a731238 100644
--- a/packages/mosaic/src/components/action-bar/action-bar.tsx
+++ b/packages/mosaic/src/components/action-bar/action-bar.tsx
@@ -129,7 +129,7 @@ const Root = React.forwardRef(function Actio
refs.setReference(anchor.current);
});
- const { mounted, transitionStatus, setMounted } = useTransitionStatus(open);
+ const { mounted, transitionStatus, setMounted } = useTransitionStatus({ open });
const runOnAnimationsFinished = useAnimationsFinished(barRef, open);
const focusWithinRef = React.useRef(false);
diff --git a/packages/mosaic/src/components/card/card.test.tsx b/packages/mosaic/src/components/card/card.test.tsx
index e218d9f8d6d..89739b7814d 100644
--- a/packages/mosaic/src/components/card/card.test.tsx
+++ b/packages/mosaic/src/components/card/card.test.tsx
@@ -354,12 +354,12 @@ describe('Mosaic Card', () => {
);
const slot = screen.getByRole('alert');
expect(slot).toHaveAttribute('data-open');
- expect(slot).toHaveAttribute('data-starting-style');
+ expect(slot).not.toHaveAttribute('data-starting-style');
expect(slot.previousElementSibling).toHaveClass('cl-card-header');
expect(slot.nextElementSibling).toHaveClass('cl-card-content');
const banner = slot.querySelector('.cl-banner-root');
expect(banner).toHaveAttribute('data-color', 'negative');
- expect(banner).toHaveAttribute('data-starting-style');
+ expect(banner).not.toHaveAttribute('data-starting-style');
expect(screen.getByText('Something went wrong.')).toHaveClass('cl-banner-label');
await act(async () => {
diff --git a/packages/mosaic/src/components/field/__tests__/field.test.tsx b/packages/mosaic/src/components/field/__tests__/field.test.tsx
index b75f8e2bbcb..23660d012c5 100644
--- a/packages/mosaic/src/components/field/__tests__/field.test.tsx
+++ b/packages/mosaic/src/components/field/__tests__/field.test.tsx
@@ -490,7 +490,7 @@ describe('Mosaic Field', () => {
expect(error?.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
});
- it('mounts through the headless enter transition and animates the message height', () => {
+ it('animates the registered message height while initially present feedback stays at rest', () => {
const probe = stylex.create({
message: {
transitionProperty: {
@@ -525,7 +525,7 @@ describe('Mosaic Field', () => {
expect(atoms(probe.feedback)).toHaveLength(3);
expect(error).toHaveClass(...atoms(probe.feedback));
expect(error).toHaveAttribute('data-open');
- expect(error).toHaveAttribute('data-starting-style');
+ expect(error).not.toHaveAttribute('data-starting-style');
});
it('renders nothing without a message', () => {
diff --git a/packages/mosaic/src/components/reveal/reveal.tsx b/packages/mosaic/src/components/reveal/reveal.tsx
index 8f867e23599..c504f1e97b1 100644
--- a/packages/mosaic/src/components/reveal/reveal.tsx
+++ b/packages/mosaic/src/components/reveal/reveal.tsx
@@ -19,14 +19,6 @@ export const Reveal = React.forwardRef(function Mos
) {
const element = React.useRef(null);
const { mounted, transitionProps } = useTransition({ open, ref: element });
- const [hasBeenClosed, setHasBeenClosed] = React.useState(!open);
- if (!open && !hasBeenClosed) {
- setHasBeenClosed(true);
- }
- const effectiveTransitionProps = hasBeenClosed
- ? transitionProps
- : { ...transitionProps, 'data-starting-style': undefined, style: undefined };
-
return useRender({
defaultTagName: 'div',
ref: [ref, element],
@@ -34,7 +26,7 @@ export const Reveal = React.forwardRef(function Mos
...mergeStyleProps(
themeProps('reveal'),
stylex.props(reset.base, reveal.root, xstyle),
- { ...effectiveTransitionProps },
+ { ...transitionProps },
rest,
),
children: (
diff --git a/packages/mosaic/src/primitives/accordion/accordion-panel.tsx b/packages/mosaic/src/primitives/accordion/accordion-panel.tsx
index 4b262f5e616..d1a38ae58c3 100644
--- a/packages/mosaic/src/primitives/accordion/accordion-panel.tsx
+++ b/packages/mosaic/src/primitives/accordion/accordion-panel.tsx
@@ -18,13 +18,6 @@ export const AccordionPanel = React.forwardRef(null);
const [height, setHeight] = useState(undefined);
- // Track whether open has ever transitioned from true→false.
- // Until that happens, skip enter animations (prevents animate-on-load).
- const hasBeenClosed = useRef(false);
- if (!open) {
- hasBeenClosed.current = true;
- }
-
const { mounted, transitionStatus, transitionProps } = useTransition({
open,
ref: panelRef,
@@ -70,23 +63,14 @@ export const AccordionPanel = React.forwardRef = {
id: panelId,
role: 'region' as const,
'aria-labelledby': triggerId,
- ...effectiveTransitionProps,
+ ...transitionProps,
style: {
'--cl-accordion-panel-height': height != null ? `${height}px` : undefined,
- ...effectiveTransitionProps.style,
+ ...transitionProps.style,
},
};
diff --git a/packages/mosaic/src/primitives/collapsible/collapsible-panel.tsx b/packages/mosaic/src/primitives/collapsible/collapsible-panel.tsx
index 446b90a73be..279411930ac 100644
--- a/packages/mosaic/src/primitives/collapsible/collapsible-panel.tsx
+++ b/packages/mosaic/src/primitives/collapsible/collapsible-panel.tsx
@@ -19,11 +19,6 @@ export const CollapsiblePanel = React.forwardRef(undefined);
const [width, setWidth] = useState(undefined);
- const hasBeenClosed = useRef(false);
- if (!open) {
- hasBeenClosed.current = true;
- }
-
const { mounted, transitionStatus, transitionProps } = useTransition({
open,
ref: panelRef,
@@ -77,23 +72,15 @@ export const CollapsiblePanel = React.forwardRef = {
id: panelId,
role: 'region' as const,
'aria-labelledby': triggerId,
- ...effectiveTransitionProps,
+ ...transitionProps,
style: {
'--collapsible-panel-height': height != null ? `${height}px` : undefined,
'--collapsible-panel-width': width != null ? `${width}px` : undefined,
- ...effectiveTransitionProps.style,
+ ...transitionProps.style,
},
};
diff --git a/packages/mosaic/src/primitives/flow/flow-step.tsx b/packages/mosaic/src/primitives/flow/flow-step.tsx
index 5f03b208574..66d6a4eb4e1 100644
--- a/packages/mosaic/src/primitives/flow/flow-step.tsx
+++ b/packages/mosaic/src/primitives/flow/flow-step.tsx
@@ -40,14 +40,11 @@ export const FlowStep = React.forwardRef(function
const open = ids.includes(value);
const stepRef = useRef(null);
const activeChildrenRef = useRef(children);
- const hasBeenClosed = useRef(false);
const focusTargetsRef = useRef(new Set());
const wasOpenRef = useRef(open);
if (open) {
activeChildrenRef.current = children;
- } else {
- hasBeenClosed.current = true;
}
const { mounted, transitionProps } = useTransition({ open, ref: stepRef });
@@ -97,16 +94,12 @@ export const FlowStep = React.forwardRef(function
[open, parentActive, registerFocusTarget, unregisterFocusTarget],
);
- const effectiveTransitionProps = !hasBeenClosed.current
- ? { ...transitionProps, 'data-starting-style': undefined, style: undefined }
- : transitionProps;
-
const defaultProps = {
- ...effectiveTransitionProps,
+ ...transitionProps,
...inertProps(!open),
'aria-hidden': !open ? true : undefined,
style: {
- ...effectiveTransitionProps.style,
+ ...transitionProps.style,
'--cl-flow-transition-direction': String(direction),
},
children: open ? children : activeChildrenRef.current,
diff --git a/packages/mosaic/src/primitives/hooks/__tests__/use-transition-status.test.ts b/packages/mosaic/src/primitives/hooks/__tests__/use-transition-status.test.ts
index c75c6c0eb83..c63fc588be4 100644
--- a/packages/mosaic/src/primitives/hooks/__tests__/use-transition-status.test.ts
+++ b/packages/mosaic/src/primitives/hooks/__tests__/use-transition-status.test.ts
@@ -33,19 +33,19 @@ describe('useTransitionStatus', () => {
}
it('returns mounted=false and status=undefined when open=false', () => {
- const { result } = renderHook(() => useTransitionStatus(false));
+ const { result } = renderHook(() => useTransitionStatus({ open: false }));
expect(result.current.mounted).toBe(false);
expect(result.current.transitionStatus).toBeUndefined();
});
- it("returns mounted=true and status='starting' when open=true", () => {
- const { result } = renderHook(() => useTransitionStatus(true));
+ it('mounts initially open content without an entrance by default', () => {
+ const { result } = renderHook(() => useTransitionStatus({ open: true }));
expect(result.current.mounted).toBe(true);
- expect(result.current.transitionStatus).toBe('starting');
+ expect(result.current.transitionStatus).toBeUndefined();
});
it('synchronously sets mounted and starting when open flips true', () => {
- const { result, rerender } = renderHook(({ open }) => useTransitionStatus(open), {
+ const { result, rerender } = renderHook(({ open }) => useTransitionStatus({ open }), {
initialProps: { open: false },
});
expect(result.current.mounted).toBe(false);
@@ -58,7 +58,7 @@ describe('useTransitionStatus', () => {
});
it('clears starting status after one rAF', () => {
- const { result } = renderHook(() => useTransitionStatus(true));
+ const { result } = renderHook(() => useTransitionStatus({ open: true, animateOnMount: true }));
expect(result.current.transitionStatus).toBe('starting');
act(() => flushRaf());
@@ -67,10 +67,10 @@ describe('useTransitionStatus', () => {
});
it('sets ending status synchronously when open flips false', () => {
- const { result, rerender } = renderHook(({ open }) => useTransitionStatus(open), {
+ const { result, rerender } = renderHook(({ open }) => useTransitionStatus({ open }), {
initialProps: { open: true },
});
- act(() => flushRaf()); // clear starting
+ act(() => flushRaf());
rerender({ open: false });
@@ -79,7 +79,7 @@ describe('useTransitionStatus', () => {
});
it('stays mounted until setMounted(false) is called', () => {
- const { result, rerender } = renderHook(({ open }) => useTransitionStatus(open), {
+ const { result, rerender } = renderHook(({ open }) => useTransitionStatus({ open }), {
initialProps: { open: true },
});
act(() => flushRaf());
@@ -92,7 +92,7 @@ describe('useTransitionStatus', () => {
});
it('clears transitionStatus when unmounted', () => {
- const { result, rerender } = renderHook(({ open }) => useTransitionStatus(open), {
+ const { result, rerender } = renderHook(({ open }) => useTransitionStatus({ open }), {
initialProps: { open: true },
});
act(() => flushRaf());
@@ -105,7 +105,7 @@ describe('useTransitionStatus', () => {
});
it('handles rapid open→close before rAF fires', () => {
- const { result, rerender } = renderHook(({ open }) => useTransitionStatus(open), {
+ const { result, rerender } = renderHook(({ open }) => useTransitionStatus({ open }), {
initialProps: { open: false },
});
@@ -123,4 +123,17 @@ describe('useTransitionStatus', () => {
act(() => flushRaf());
expect(result.current.transitionStatus).toBe('ending');
});
+
+ it('does not replay an entrance when the mount option changes', () => {
+ const { result, rerender } = renderHook(
+ ({ animateOnMount }) => useTransitionStatus({ open: true, animateOnMount }),
+ {
+ initialProps: { animateOnMount: false },
+ },
+ );
+
+ rerender({ animateOnMount: true });
+
+ expect(result.current.transitionStatus).toBeUndefined();
+ });
});
diff --git a/packages/mosaic/src/primitives/hooks/__tests__/use-transition.test.ts b/packages/mosaic/src/primitives/hooks/__tests__/use-transition.test.ts
index ccf3e195f23..5dc3c128f4f 100644
--- a/packages/mosaic/src/primitives/hooks/__tests__/use-transition.test.ts
+++ b/packages/mosaic/src/primitives/hooks/__tests__/use-transition.test.ts
@@ -61,29 +61,84 @@ describe('useTransition', () => {
expect(result.current.transitionProps).toEqual({});
});
- it('returns mounted=true with starting-style props on open', () => {
+ it('mounts initially open content without starting style by default', () => {
const ref = createElementRef();
const { result } = renderHook(() => useTransition({ open: true, ref }));
expect(result.current.mounted).toBe(true);
+ expect(result.current.transitionStatus).toBeUndefined();
+ expect(result.current.transitionProps).toEqual({ 'data-open': '' });
+ });
+
+ it('clears opted-in starting style after first frame, keeps data-open', () => {
+ const ref = createElementRef();
+ const { result } = renderHook(() => useTransition({ open: true, ref, animateOnMount: true }));
+
expect(result.current.transitionProps).toEqual({
'data-open': '',
'data-starting-style': '',
style: { transition: 'none' },
});
+
+ act(() => flushRaf());
+
+ expect(result.current.transitionProps).toEqual({
+ 'data-open': '',
+ });
});
- it('clears starting-style after first frame, keeps data-open', () => {
+ it('animates a later opening and a reopen with the default mount policy', async () => {
const ref = createElementRef();
- const { result } = renderHook(() => useTransition({ open: true, ref }));
+ const { result, rerender } = renderHook(({ open }) => useTransition({ open, ref }), {
+ initialProps: { open: false },
+ });
+ rerender({ open: true });
+ expect(result.current.transitionProps['data-starting-style']).toBe('');
act(() => flushRaf());
- expect(result.current.transitionProps).toEqual({
- 'data-open': '',
+ await act(async () => {
+ rerender({ open: false });
+ await new Promise(resolve => setTimeout(resolve, 0));
});
+ expect(result.current.mounted).toBe(false);
+
+ rerender({ open: true });
+ expect(result.current.transitionProps['data-starting-style']).toBe('');
});
+ it('does not replay an entrance when the mount option changes', () => {
+ const ref = createElementRef();
+ const { result, rerender } = renderHook(
+ ({ animateOnMount }) => useTransition({ open: true, ref, animateOnMount }),
+ { initialProps: { animateOnMount: false } },
+ );
+
+ rerender({ animateOnMount: true });
+ expect(result.current.transitionProps).toEqual({ 'data-open': '' });
+ });
+
+ it.each([undefined, false, true])(
+ 'animates reopen after an initially open instance closes (%s)',
+ async animateOnMount => {
+ const ref = createElementRef();
+ const { result, rerender } = renderHook(({ open }) => useTransition({ open, ref, animateOnMount }), {
+ initialProps: { open: true },
+ });
+
+ expect(result.current.transitionStatus).toBe(animateOnMount ? 'starting' : undefined);
+ act(() => flushRaf());
+ await act(async () => {
+ rerender({ open: false });
+ await new Promise(resolve => setTimeout(resolve, 0));
+ });
+ expect(result.current.mounted).toBe(false);
+
+ rerender({ open: true });
+ expect(result.current.transitionProps['data-starting-style']).toBe('');
+ },
+ );
+
it('sets closing props while animations are running', async () => {
const { ref, el, resolveAnim } = createAnimatingRef();
const { result, rerender } = renderHook(({ open }) => useTransition({ open, ref }), {
diff --git a/packages/mosaic/src/primitives/hooks/use-transition-status.ts b/packages/mosaic/src/primitives/hooks/use-transition-status.ts
index 500a0c1585a..eaa73c9bcf5 100644
--- a/packages/mosaic/src/primitives/hooks/use-transition-status.ts
+++ b/packages/mosaic/src/primitives/hooks/use-transition-status.ts
@@ -4,13 +4,18 @@ import { useEffect, useState } from 'react';
export type TransitionStatus = 'starting' | 'ending' | undefined;
+export interface UseTransitionStatusOptions {
+ open: boolean;
+ animateOnMount?: boolean;
+}
+
/**
* Core state machine for enter/exit animations.
*
* Tracks whether a component should be in the DOM (`mounted`) and which
* animation phase it is in (`transitionStatus`).
*
- * - When `open` becomes true: synchronously sets `mounted=true` and
+ * - When `open` becomes true after mount: synchronously sets `mounted=true` and
* `transitionStatus='starting'` during render so the first committed DOM
* frame carries `[data-starting-style]`. One animation frame later, clears
* the status so the CSS transition fires.
@@ -18,9 +23,11 @@ export type TransitionStatus = 'starting' | 'ending' | undefined;
* The element stays mounted until the caller explicitly calls `setMounted(false)`
* (typically after all CSS animations have finished).
*/
-export function useTransitionStatus(open: boolean) {
+export function useTransitionStatus({ open, animateOnMount = false }: UseTransitionStatusOptions) {
const [mounted, setMounted] = useState(open);
- const [transitionStatus, setTransitionStatus] = useState(open ? 'starting' : undefined);
+ const [transitionStatus, setTransitionStatus] = useState(
+ open && animateOnMount ? 'starting' : undefined,
+ );
// Synchronous render-phase state updates. Running these during render (not in
// useEffect) guarantees the first committed DOM includes the right data
diff --git a/packages/mosaic/src/primitives/hooks/use-transition.ts b/packages/mosaic/src/primitives/hooks/use-transition.ts
index 0641f6e9ff5..4d5d9a5ced9 100644
--- a/packages/mosaic/src/primitives/hooks/use-transition.ts
+++ b/packages/mosaic/src/primitives/hooks/use-transition.ts
@@ -8,6 +8,7 @@ import { type TransitionStatus, useTransitionStatus } from './use-transition-sta
export interface UseTransitionOptions {
open: boolean;
ref: RefObject;
+ animateOnMount?: boolean;
}
export interface TransitionProps {
@@ -32,14 +33,15 @@ export interface UseTransitionReturn {
* - `transitionProps`: spread onto the element. Exposes
* `data-open` / `data-closed` / `data-starting-style` /
* `data-ending-style` data attributes and an inline `transition: none` on
- * the first mount frame.
+ * the first frame of an entrance. Initially open content only enters when
+ * `animateOnMount` is true.
*
* Consumers drive all animation via CSS — no durations in JS. Works with CSS
* transitions (via `[data-starting-style]` / `[data-ending-style]`) and
* CSS keyframe animations (via `[data-open]` / `[data-closed]`).
*/
-export function useTransition({ open, ref }: UseTransitionOptions): UseTransitionReturn {
- const { mounted, transitionStatus, setMounted } = useTransitionStatus(open);
+export function useTransition({ open, ref, animateOnMount }: UseTransitionOptions): UseTransitionReturn {
+ const { mounted, transitionStatus, setMounted } = useTransitionStatus({ open, animateOnMount });
const runOnAnimationsFinished = useAnimationsFinished(ref, open);
useEffect(() => {
diff --git a/packages/mosaic/src/primitives/tabs/tabs-panel.tsx b/packages/mosaic/src/primitives/tabs/tabs-panel.tsx
index f88ea085a65..50b569be635 100644
--- a/packages/mosaic/src/primitives/tabs/tabs-panel.tsx
+++ b/packages/mosaic/src/primitives/tabs/tabs-panel.tsx
@@ -27,18 +27,6 @@ export const TabsPanel = React.forwardRef(functi
ref: panelRef,
});
- // Suppress enter animation on initial mount so the initially-selected panel
- // appears instantly. After the panel has been deselected once, subsequent
- // selections will animate normally. Matches the Accordion pattern.
- const hasBeenDeselected = useRef(false);
- if (!isSelected) {
- hasBeenDeselected.current = true;
- }
-
- const effectiveTransitionProps = !hasBeenDeselected.current
- ? { ...transitionProps, 'data-starting-style': undefined, style: undefined }
- : transitionProps;
-
const state = { hidden: !mounted };
const defaultProps = {
@@ -48,9 +36,9 @@ export const TabsPanel = React.forwardRef(functi
tabIndex: 0,
...inertProps(!isSelected),
hidden: mounted ? undefined : true,
- ...effectiveTransitionProps,
+ ...transitionProps,
style: {
- ...effectiveTransitionProps.style,
+ ...transitionProps.style,
'--cl-tab-transition-direction': direction === 'forward' ? '1' : '-1',
},
children: keepMounted || mounted ? {children} : null,
diff --git a/packages/mosaic/src/primitives/tag-input/tag-input-tag.tsx b/packages/mosaic/src/primitives/tag-input/tag-input-tag.tsx
index 3f4d7e26f11..bb863a7194f 100644
--- a/packages/mosaic/src/primitives/tag-input/tag-input-tag.tsx
+++ b/packages/mosaic/src/primitives/tag-input/tag-input-tag.tsx
@@ -22,7 +22,7 @@ export const TagInputTag = React.forwardRef(
const invalid = tags.find(tag => tag.value === tagValue)?.invalid ?? false;
const elementRef = useRef(null);
- const { mounted, transitionProps } = useTransition({ open: present, ref: elementRef });
+ const { mounted, transitionProps } = useTransition({ open: present, ref: elementRef, animateOnMount: true });
useEffect(() => {
if (!mounted) {
diff --git a/packages/mosaic/src/primitives/tag-input/tag-input.test.tsx b/packages/mosaic/src/primitives/tag-input/tag-input.test.tsx
index 93914763d9b..ccac1b0fdaa 100644
--- a/packages/mosaic/src/primitives/tag-input/tag-input.test.tsx
+++ b/packages/mosaic/src/primitives/tag-input/tag-input.test.tsx
@@ -83,6 +83,12 @@ function holdExitAnimations() {
describe('TagInput', () => {
describe('adding', () => {
+ it('starts the entrance for a tag present on mount', () => {
+ render();
+
+ expect(tag('a@clerk.dev')).toHaveAttribute('data-starting-style');
+ });
+
it('adds the typed text as a tag on Enter and clears the input', async () => {
const user = userEvent.setup();
const onValueChange = vi.fn();
diff --git a/packages/mosaic/src/primitives/toast/toast-root.tsx b/packages/mosaic/src/primitives/toast/toast-root.tsx
index 01b43a8b206..93203713021 100644
--- a/packages/mosaic/src/primitives/toast/toast-root.tsx
+++ b/packages/mosaic/src/primitives/toast/toast-root.tsx
@@ -88,7 +88,7 @@ export const ToastRoot = React.forwardRef(functi
const [descriptionId, setDescriptionId] = useState(undefined);
const open = toast.transitionStatus !== 'ending';
- const { mounted, transitionProps } = useTransition({ open, ref: rootRef });
+ const { mounted, transitionProps } = useTransition({ open, ref: rootRef, animateOnMount: true });
useEffect(() => {
if (!mounted) {
diff --git a/packages/mosaic/src/primitives/toast/toast.test.tsx b/packages/mosaic/src/primitives/toast/toast.test.tsx
index dfaca03b59d..7042d2eafde 100644
--- a/packages/mosaic/src/primitives/toast/toast.test.tsx
+++ b/packages/mosaic/src/primitives/toast/toast.test.tsx
@@ -60,6 +60,14 @@ function renderToast(
describe('Toast', () => {
describe('adding', () => {
+ it('starts the entrance when a toast arrives', () => {
+ renderToast();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Add toast' }));
+
+ expect(screen.getByRole('dialog', { name: 'Saved' })).toHaveAttribute('data-starting-style');
+ });
+
it('renders a toast added through useToastManager', async () => {
const user = userEvent.setup();
renderToast();
diff --git a/packages/mosaic/src/styles/feedback.styles.ts b/packages/mosaic/src/styles/feedback.styles.ts
index eeac8068e2e..f6f3d1e481c 100644
--- a/packages/mosaic/src/styles/feedback.styles.ts
+++ b/packages/mosaic/src/styles/feedback.styles.ts
@@ -64,5 +64,7 @@ export const feedbackStyles = stylex.create({
});
export const feedbackHeight = stylex.create({
- measured: (height: number) => ({ '--_cl-feedback-height': `${height}px` }),
+ measured: (height: number | undefined) => ({
+ '--_cl-feedback-height': height === undefined ? null : `${height}px`,
+ }),
});
diff --git a/packages/mosaic/src/utils/feedback.tsx b/packages/mosaic/src/utils/feedback.tsx
index 25298625ec0..b1b4857cde9 100644
--- a/packages/mosaic/src/utils/feedback.tsx
+++ b/packages/mosaic/src/utils/feedback.tsx
@@ -11,7 +11,7 @@ export function hasMessage(children: React.ReactNode) {
}
export function useMessageHeight(active: HTMLElement | null) {
- const [height, setHeight] = React.useState(0);
+ const [height, setHeight] = React.useState(undefined);
useSafeLayoutEffect(() => {
if (!active) {