react-spring: A Practical Walkthrough

Animation in React often feels like an afterthought—something you bolt on once the UI is otherwise finished. react-spring takes a different route by letting you define what values you want and letting spring physics handle the motion between them. It's not the only option, but it's one of the most flexible and widely used.

This guide covers version 9, which is currently in release candidate status. If it hasn't hit stable yet, install it with react-spring@next. The API is solid; the main caveat is a known issue when used with concurrent mode, tracked in the project's GitHub repo.

The Core Idea: Springs

Start with the simplest animation problem: fading content in and out. Without animation, you toggle opacity by state:

export default function App() {
  const [showing, setShowing] = useState(false);
  return (
    <div>
      <div style={{ opacity: showing ? 1 : 0 }}>
        This content will fade in and fade out
      </div>
      <button onClick={() => setShowing(val => !val)}>Toggle</button>
      <hr />
    </div>
  );
}

To make that smooth, react-spring acts as an intermediary that tweens your style values. You declare the starting point with from, specify the target in to, and the hook returns the interpolated styles:

const [showA, setShowA] = useState(false);


const fadeStyles = useSpring({
  config: { ...config.stiff },
  from: { opacity: 0 },
  to: {
    opacity: showA ? 1 : 0
  }
});

The catch: rendered elements must come from the animated export, not plain DOM elements:

<div style={fadeStyles}>
<animated.div style={fadeStyles}>

Everything else in your component stays the same.

Animating Height

Sliding content up and down requires animating height from zero to its full size. The snag is, you can't animate to auto—not in vanilla CSS, and not here. react-spring needs a concrete pixel value.

The platform provides ResizeObserver for exactly this: measuring an element's dimensions live. Wrapped in a hook, it returns a ref to attach to your content and a height value:

export function useHeight({ on = true /* no value means on */ } = {} as any) {
  const ref = useRef<any>();
  const [height, set] = useState(0);
  const heightRef = useRef(height);
  const [ro] = useState(
    () =>
      new ResizeObserver(packet => {
        if (ref.current && heightRef.current !== ref.current.offsetHeight) {
          heightRef.current = ref.current.offsetHeight;
          set(ref.current.offsetHeight);
        }
      })
  );
  useLayoutEffect(() => {
    if (on && ref.current) {
      set(ref.current.offsetHeight);
      ro.observe(ref.current, {});
    }
    return () => ro.disconnect();
  }, [on, ref.current]);
  return [ref, height as any];
}

Pass that height into your spring and apply the ref:

const [heightRef, height] = useHeight();
const slideInStyles = useSpring({
  config: { ...config.stiff },
  from: { opacity: 0, height: 0 },
  to: {
    opacity: showB ? 1 : 0,
    height: showB ? height : 0
  }
});
<animated.div style={{ ...slideInStyles, overflow: "hidden" }}>
  <div ref={heightRef}>
    This content will fade in and fade out with sliding
  </div>
</animated.div>

Remember to add overflow: hidden to the container so the animating height clips correctly.

Transitions: Entering and Leaving

Animating items that mount and unmount requires a different hook: useTransition. It tracks your list and gives you a function to render each item with the right styles, depending on whether it's new, exiting, or static.

For a simple list:

const [list, setList] = useState([]);

Define the transition:

const listTransitions = useTransition(list, {
  config: config.gentle,
  from: { opacity: 0, transform: "translate3d(-25%, 0px, 0px)" },
  enter: { opacity: 1, transform: "translate3d(0%, 0px, 0px)" },
  leave: { opacity: 0, height: 0, transform: "translate3d(25%, 0px, 0px)" },
  keys: list.map((item, index) => index)
});

Note the keys section, which tells react-spring how to identify items. Index-based keys are poor practice in real apps, but they work for a basic demo. The hook returns a function that you call with a callback that receives each item and its animating styles.

The library derives its name from the physics behind the motion. Values are interpolated via a spring algorithm rather than a fixed set of steps, which produces a natural, sometimes bouncy trajectory. The behavior is configurable through presets like stiff and gentle. One artifact: when animating content that bounces up, the bottom edge can look jittery at the tail end. The clamp property turns that overshoot off.

A Realistic Modal

Springs and transitions get you far, but integrating them into a live UI surfaces subtleties not obvious from the docs. Consider a modal built on Reach UI's Dialog component. To fade in the backdrop and transition the content, wrap it in useTransition based on the isOpen prop:

const modalTransition = useTransition(!!isOpen, {
  config: isOpen ? { ...config.stiff } : { duration: 150 },
  from: { opacity: 0, transform: `translate3d(0px, -10px, 0px)` },
  enter: { opacity: 1, transform: `translate3d(0px, 0px, 0px)` },
  leave: { opacity: 0, transform: `translate3d(0px, 10px, 0px)` }
});

One important detail: when the modal is closing, backdrop fade-out can feel slow, leaving the underlying page un-interactive too long. Instead of using spring physics for both directions, apply spring config only while opening. On close, use a fixed 150ms duration that snaps the backdrop away immediately:

config: isOpen ? { ...config.stiff } : { duration: 150 },

Render the backdrop and content through the transition function:

return modalTransition(
  (styles, isOpen) =>
    isOpen && (
      <AnimatedDialogOverlay
        allowPinchZoom={true}
        initialFocusRef={focusRef}
        onDismiss={onHide}
        isOpen={isOpen}
        style={{ opacity: styles.opacity }}
      >
      <AnimatedDialogContent
        style={{
          border: "4px solid hsla(0, 0%, 0%, 0.5)",
          borderRadius: 10,
          maxWidth: "400px",
          ...styles
        }}
      >
        <div>
          <div>
            <StandardModalHeader caption={headerCaption} onHide={onHide} />
            {children}
          </div>
        </div>
      </AnimatedDialogContent>
    </AnimatedDialogOverlay>
  )
);

Sizing to Content

When search results load, the modal should resize smoothly to fit them, but only after it's fully rendered. Measure the content height with the useHeight hook, and use immediate so the first sizing doesn't animate from zero:

const [heightOn, setHeightOn] = useState(false);
const [sizingRef, contentHeight] = useHeight({ on: heightOn });
const uiReady = useRef(false);

A callback ref tells you when the DOM node exists. Here, it activates the height measurement:

const activateRef = ref => {
  sizingRef.current = ref;
  if (!heightOn) {
    setHeightOn(true);
  }
};

To prevent any initial animation from zero height, keep the spring in immediate mode until the first measurement finishes, then flip it via onRest:

const heightStyles = useSpring({
  immediate: !uiReady.current,
  config: { ...config.stiff },
  from: { height: 0 },
  to: { height: contentHeight },
  onRest: () => (uiReady.current = true)
});

This boilerplate exists only because the modal renders its children before they're visible. In a scenario where the correct height is available from the first render, the hook simplifies sharply:

const heightStyles = useSpring({
  to: {
    height: contentHeight
  },
  config: config.stiff,
})

Which reduces to:

const heightStyles = useSpring({
  height: contentHeight,
  config: config.stiff,
})

One more thing to note: the height hook continues observing a node that's no longer mounted when the modal closes. Cancelling the observer cleans up that gap:

useLayoutEffect(() => {
  if (!isOpen) {
    setHeightOn(false);
  }
}, [isOpen]);

Animating Result Swaps

Search results should cross-fade when new data arrives, not hard-cut. Pluck the result set off the GraphQL response and feed it to useTransition:

{booksObj.Books.map(book => (
  <SearchResult
    key={book._id}
    book={book}
    selected={selectedBooksMap[book._id]}
    selectBook={selectBook}
    dispatch={props.dispatch}
  />
))}
const resultsTransition = useTransition(booksObj, {
  config: { ...config.default },
  from: {
    opacity: 0,
    position: "static",
    transform: "translate3d(0%, 0px, 0px)"
  },
  enter: {
    opacity: 1,
    position: "static",
    transform: "translate3d(0%, 0px, 0px)"
  },
  leave: {
    opacity: 0,
    position: "absolute",
    transform: "translate3d(90%, 0px, 0px)"
  }
});

Ensure the outgoing result set is absolutely positioned so it doesn't affect the parent's height—the modal itself can then animate its own size independently:

<div className="overlay-holder">
  {resultsTransition((styles, booksObj) =>
    booksObj?.Books?.length ? (
      <animated.div style={styles}>
        {booksObj.Books.map(book => (
          <SearchResult
            key={book._id}
            book={book}
            selected={selectedBooksMap[book._id]}
            selectBook={selectBook}
            dispatch={props.dispatch}
          />
        ))}
      </animated.div>
    ) : null
  )}

Removing Items Inside a Resizing Container

When selecting a book, it fades and slides out to the right while the list shrinks. But things get messy if you rapid-fire select many items. As each book starts animating out, the modal's height animation trails it, producing an awkward bouncy chain.

A functional but visually noisy version sets a spring on each item's height, using selected to trigger the departure:

const SearchResult = props => {
  let { book, selectBook, selected } = props;


  const initiallySelected = useRef(selected);
  const [sizingRef, currentHeight] = useHeight();


  const heightStyles = useSpring({
    config: { ...config.stiff, clamp: true },
    from: {
      opacity: initiallySelected.current ? 0 : 1,
      height: initiallySelected.current ? 0 : currentHeight,
      transform: "translate3d(0%, 0px, 0px)"
    },
    to: {
      opacity: selected ? 0 : 1,
      height: selected ? 0 : currentHeight,
      transform: `translate3d(${selected ? "25%" : "0%"},0px,0px)`
    }
  }); 

The fix: give the modal an override state that disables its own animations while inner content is moving. Add state to the modal:

const animatModalSizing = useRef(true);
const modalSizingPacket = useMemo(() => {
  return {
    disable() {
      animatModalSizing.current = false;
    },
    enable() {
      animatModalSizing.current = true;
    }
  };
}, []);

Attach it to the transition:

const heightStyles = useSpring({
  immediate: !uiReady.current || !animatModalSizing.current,
  config: { ...config.stiff },
  from: { height: 0 },
  to: { height: contentHeight },
  onRest: () => (uiReady.current = true)
});

Expose it to descendants via context:

export const ModalSizingContext = createContext(null);
<ModalSizingContext.Provider value={modalSizingPacket}>

Consumer components read it:

const { enable: enableModalSizing, disable: disableModalSizing } = useContext(
  ModalSizingContext
);

Then set it in their springs. A short delay (setTimeout) ensures the modal stays still until everything settles:

const heightStyles = useSpring({
  config: { ...config.stiff, clamp: true },
  from: {
    opacity: initiallySelected.current ? 0 : 1,
    height: initiallySelected.current ? 0 : currentHeight,
    transform: "translate3d(0%, 0px, 0px)"
  },
  to: {
    opacity: selected ? 0 : 1,
    height: selected ? 0 : currentHeight,
    transform: `translate3d(${selected ? "25%" : "0%"},0px,0px)`
  },
  onStart() {
    if (uiReady.current) {
      disableModalSizing();
    }
  },
  onRest() {
    uiReady.current = true;
    setTimeout(() => {
      enableModalSizing();
    });
  }
});

Bringing Items In and Out

On the main screen under the modal, selected books should slide in from the left on selection, then slide out right while collapsing when removed. Each item has its own height, which the transition needs per-item. Map each book's ID to its measured height:

const [displaySizes, setDisplaySizes] = useState({});
const setDisplaySize = useCallback(
  (_id, height) => {
    setDisplaySizes(displaySizes => ({ ...displaySizes, [_id]: height }));
  },
  [setDisplaySizes]
);

The book's component reports back its measured height when available—not before, so the item doesn't prematurely collapse to zero. Wait for an actual value before setting it:

const SelectedBook = props => {
  let { book, removeBook, styles, setDisplaySize } = props;
  const [ref, height] = useHeight();
  useLayoutEffect(() => {
    height && setDisplaySize(book._id, height);
  }, [height]);

The transition accepts a function for to so it can compute per-item values. The update callback keeps the map current if heights change, such as when resizing the viewport:

const selectedBookTransitions = useTransition(selectedBooks, {
  config: book => ({
    ...config.stiff,
    clamp: !selectedBooksMap[book._id]
  }),
  from: { opacity: 0, transform: "translate3d(-25%, 0px, 0px)" },
  enter: book => ({
    opacity: 1,
    height: displaySizes[book._id],
    transform: "translate3d(0%, 0px, 0px)"
  }),
  update: book => ({ height: displaySizes[book._id] }),
  leave: { opacity: 0, height: 0, transform: "translate3d(25%, 0px, 0px)" }
});

Conditionally applying clamp here controls the bounce: on while items enter, giving a slight springy feel, and off when leaving, preventing the exit animation from going through the same jittery tail we saw earlier.

A Bonus Refactor

There's a bug in the modal height animation as written: if the modal's height changes while hidden, the stale size lingers and animates awkwardly on the next open. The root cause is that measurement stays active even when the modal is unrendered. Fixing it also simplifies the code meaningfully.

The modal originally renders children inside itself:

<animated.div style={{ overflow: "hidden", ...heightStyles }}>
  <div style={{ padding: "10px" }} ref={activateRef}>
    <StandardModalHeader
      caption={headerCaption}
      onHide={onHide}
    />
    {children}
  </div>
</animated.div>

Move those children—along with the height hooks—into a dedicated component that only mounts when the modal is actually shown:

const ModalContents = ({ header, contents, onHide, animatModalSizing }) => {
  const [sizingRef, contentHeight] = useHeight();
  const uiReady = useRef(false);

  const heightStyles = useSpring({
    immediate: !uiReady.current || !animatModalSizing.current,
    config: { ...config.stiff },
    from: { height: 0 },
    to: { height: contentHeight },
    onRest: () => (uiReady.current = true)
  });

  return (
    <animated.div style={{ overflow: "hidden", ...heightStyles }}>
      <div style={{ padding: "10px" }} ref={sizingRef}>
        <StandardModalHeader caption={header} onHide={onHide} />
        {contents}
      </div>
    </animated.div>
  );
};

This eliminates the activation ref and the conditional measurement logic entirely. The component starts fresh each time, with a ref on a div whose content is already guaranteed present. Initial height is still unknown until the layout effect runs, so uiReady must remain—otherwise the spring would animate relentlessly from zero.

The result is less state, fewer guards, and a modal that always opens at the correct size.

Final Notes

react-spring is a low-level toolkit. That's a feature as much as a barrier—once you work past the initial hook signatures, it gives you fine-grained control over complex choreography that higher-level libraries restrict. The strategy for complex UIs: start with a functioning page, then replace instantaneous state changes with springs one at a time.