Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions docs/components/AnimatedPresence/Web.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,3 +196,149 @@ Stepper.parameters = {
},
},
};

const ExitBehaviorTemplate: ComponentStory<typeof AnimatedPresence> = () => {
const [activeTab, setActiveTab] = useState<"red" | "green" | "blue">("red");

const tabs = [
{ id: "red" as const, label: "Red", color: "#fee2e2" },
{ id: "green" as const, label: "Green", color: "#dcfce7" },
{ id: "blue" as const, label: "Blue", color: "#dbeafe" },
];

const tabContent = {
red: (
<div
key="red"
style={{
backgroundColor: "#fee2e2",
padding: 24,
borderRadius: 8,
minHeight: 150,
}}
>
<Heading level={3}>Red Panel</Heading>
<Text>
This is the red content panel with some text to give it height.
</Text>
</div>
),
green: (
<div
key="green"
style={{
backgroundColor: "#dcfce7",
padding: 24,
borderRadius: 8,
minHeight: 200,
}}
>
<Heading level={3}>Green Panel</Heading>
<Text>
This is the green content panel. It&apos;s intentionally taller to
show how different exit behaviors handle height changes during
transitions.
</Text>
<Text>Extra line for more height.</Text>
</div>
),
blue: (
<div
key="blue"
style={{
backgroundColor: "#dbeafe",
padding: 24,
borderRadius: 8,
minHeight: 120,
}}
>
<Heading level={3}>Blue Panel</Heading>
<Text>This is the blue content panel. It&apos;s the shortest one.</Text>
</div>
),
};

return (
<Content>
<Text>
Compare how the three exit behaviors handle the same tab switch. Watch
for layout shifts, overlap, and timing differences.
</Text>

<Flex template={["shrink", "shrink", "shrink"]} gap="small">
{tabs.map(tab => (
<Button
key={tab.id}
label={tab.label}
type={activeTab === tab.id ? "primary" : "secondary"}
onClick={() => setActiveTab(tab.id)}
/>
))}
</Flex>

<Divider size="large" />

<Flex template={["grow", "grow", "grow"]} align="start" gap="base">
<Content>
<Heading level={4}>overlap</Heading>
<Text size="small" variation="subdued">
Both visible during transition, both in layout flow
</Text>
<div style={{ position: "relative", minHeight: 200 }}>
<AnimatedPresence
timing="timing-slowest"
transition="fromBottom"
exitBehavior="overlap"
initial={false}
>
{tabContent[activeTab]}
</AnimatedPresence>
</div>
</Content>

<Content>
<Heading level={4}>replace</Heading>
<Text size="small" variation="subdued">
Exit removed from flow, enter takes its place immediately
</Text>
<div style={{ position: "relative", minHeight: 200 }}>
<AnimatedPresence
timing="timing-slowest"
transition="fromBottom"
exitBehavior="replace"
initial={false}
>
{tabContent[activeTab]}
</AnimatedPresence>
</div>
</Content>

<Content>
<Heading level={4}>sequential</Heading>
<Text size="small" variation="subdued">
Exit completes before enter begins
</Text>
<div style={{ position: "relative", minHeight: 200 }}>
<AnimatedPresence
timing="timing-slowest"
transition="fromBottom"
exitBehavior="sequential"
initial={false}
>
{tabContent[activeTab]}
</AnimatedPresence>
</div>
</Content>
</Flex>
</Content>
);
};

export const ExitBehavior = ExitBehaviorTemplate.bind({});
ExitBehavior.parameters = {
previewTabs: {
code: {
hidden: false,
},
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ import type { Variants } from "framer-motion";

export const TIMING_QUICK = toSeconds(tokens["timing-quick"]);
export const TIMING_BASE = toSeconds(tokens["timing-base"]);
export const TIMING_SLOW = toSeconds(tokens["timing-slow"]);
export const TIMING_SLOWER = toSeconds(tokens["timing-slower"]);
export const TIMING_SLOWEST = toSeconds(tokens["timing-slowest"]);
export const TIMING_LOADING = toSeconds(tokens["timing-loading"]);
export const TIMING_LOADING_EXTENDED = toSeconds(
tokens["timing-loading--extended"],
);

const baseTransition: Variants = {
visible: { opacity: 1 },
Expand Down
55 changes: 53 additions & 2 deletions packages/components/src/AnimatedPresence/AnimatedPresence.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import type { PropsWithChildren } from "react";
import React, { Children, useEffect } from "react";
import type { tokens } from "@jobber/design";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import {
TIMING_BASE,
TIMING_LOADING,
TIMING_LOADING_EXTENDED,
TIMING_QUICK,
TIMING_SLOW,
TIMING_SLOWER,
TIMING_SLOWEST,
fade,
fromBottom,
fromLeft,
Expand All @@ -15,6 +21,16 @@ import {
} from "./AnimatedPresence.transitions";
import { usePreviousValue } from "./hooks/usePreviousValue";

const timingMap = {
"timing-base": TIMING_BASE,
"timing-quick": TIMING_QUICK,
"timing-slow": TIMING_SLOW,
"timing-slower": TIMING_SLOWER,
"timing-slowest": TIMING_SLOWEST,
"timing-loading": TIMING_LOADING,
"timing-loading--extended": TIMING_LOADING_EXTENDED,
};

const transitions = {
fromBottom,
fromTop,
Expand All @@ -26,18 +42,46 @@ const transitions = {
fade,
};

const exitBehaviorMap: Record<ExitBehavior, "wait" | "sync" | "popLayout"> = {
overlap: "sync",
replace: "popLayout",
sequential: "wait",
};

type Timing = {
[K in keyof typeof tokens]: K extends `timing-${string}` ? K : never;
}[keyof typeof tokens];

export type AnimatedPresenceTransitions = keyof typeof transitions;

type ExitBehavior = "overlap" | "replace" | "sequential";
Copy link
Contributor Author

@ZakaryH ZakaryH Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let me know if these are good, clear names.

I had a couple other ideas to describe "overlap" (crossfade)
and "immediate" instead of "replace"

the actual framer motion values they map to are:

  • "overlap" -> "sync"
  • "replace" -> "popLayout"
  • "sequential" -> "wait"

and here's their descriptions

    /**
     * Determines how to handle entering and exiting elements.
     *
     * - `"sync"`: Default. Elements animate in and out as soon as they're added/removed.
     * - `"popLayout"`: Exiting elements are "popped" from the page layout, allowing sibling
     *      elements to immediately occupy their new layouts.
     * - `"wait"`: Only renders one component at a time. Wait for the exiting component to animate out
     *      before animating the next component in.
     *
     * @public
     */
    mode?: "sync" | "popLayout" | "wait";


interface AnimatedPresenceProps extends Required<PropsWithChildren> {
/**
* The type of transition you can use.
*/
readonly transition?: AnimatedPresenceTransitions;
/**
* The timing of the animation.
*/
readonly timing?: Timing;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not 100% on this interface

you'll be passing values like "timing-slow" rather than just "slow"

buut it is derived from the tokens which is nice. the only time it might become a hindrance is if we change the token values, the props would need to get fixed in the invocations.


/**
* The mode of the animation.
*
* @default "popLayout"
*/
readonly exitBehavior?: ExitBehavior;

/**
* Whether or not to animate the children on mount. By default it's set to false.
*/
readonly initial?: boolean;

/**
* Callback called when all exiting elements have completed their animation.
*/
readonly onExitComplete?: () => void;
}

export function AnimatedPresence(props: AnimatedPresenceProps) {
Expand All @@ -53,7 +97,10 @@ export function AnimatedPresence(props: AnimatedPresenceProps) {
function InternalAnimatedPresence({
transition = "fromTop",
initial = false,
exitBehavior = "replace",
children,
timing = "timing-base",
onExitComplete,
}: AnimatedPresenceProps) {
const transitionVariation = transitions[transition];
const hasInitialTransition = "initial" in transitionVariation;
Expand All @@ -69,7 +116,11 @@ function InternalAnimatedPresence({
}, [childCount]);

return (
<AnimatePresence initial={initial} mode="popLayout">
<AnimatePresence
initial={initial}
mode={exitBehaviorMap[exitBehavior]}
onExitComplete={onExitComplete}
>
{Children.map(
children,
(child, i) =>
Expand All @@ -81,7 +132,7 @@ function InternalAnimatedPresence({
animate="visible"
exit="hidden"
transition={{
duration: TIMING_BASE,
duration: timingMap[timing],
delay: generateDelayTime(i),
}}
>
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -1,5 +1,31 @@
## Configuration

### Timing

The default timing value for the animation is "timing-base"; however if you wish
to speed it up or slow it down all valid timing token values are accepted via
the `timing` prop.

### Exit Behavior

#### Overlay

Elements animate in and out as they are added or removed.

Note that this can cause visible and possibly undesirable shifts in the layout
eg. double height because both elements will be present as one animates out and
the other animates in.

#### Replace

The exiting element is immediately removed, allowing sibling elements to occupy
the new layout.

#### Sequential

One component is rendered at a time. The entering component will wait for the
exiting component to animate out.

### Conditional rendering

Hide and show elements with a transition to add emphasis on the change. This is
Expand Down
Loading