Animation × Craft

10 min

Animation × Craft

A beautifully animated drawer that doesn't return focus when it closes is broken. Not aesthetically broken—functionally broken. The animation makes the experience feel polished. The missing focus management makes it actually inaccessible. This is the intersection where motion and craft-level interaction details must work as a single system.

Focus management after animated transitions

When a modal opens with a spring animation, focus should move to the first focusable element inside it—but when? If you move focus at the start of the animation, screen readers announce content before it's visually present. If you wait until the animation completes, there's a gap where keyboard users can interact with elements behind the modal.

The correct pattern: move focus when the entrance animation reaches a perceptually "arrived" state—typically around 60–70% through the transition, when the modal is mostly visible even if it's still settling. With spring animations, this is the moment the element first reaches its target position, before the overshoot and settle.

For exits, the timing is more critical. When a drawer closes, focus must return to the element that opened it. Not to the body. Not to the next focusable element. To the trigger. And this restoration must happen after the exit animation completes—if you restore focus mid-animation, the user might see a brief flash of the focus ring on the trigger while the drawer is still visible.

// After exit animation completes
onAnimationComplete={() => {
  triggerRef.current?.focus();
}}

This is invisible work. Users will never notice correct focus management. They will absolutely notice when it's wrong—the keyboard cursor jumps to the top of the page, they lose their place, they have to tab through the entire navigation again.

Loading-state timing

Animation duration determines when you should show a loading indicator—and getting this wrong creates one of the most common micro-frustrations in web interfaces.

The pattern: if an action typically completes in under 200ms, show no loading indicator at all. If it takes 200–500ms, show a subtle state change—dim the button, show a checkmark. Only if it exceeds 500ms should you show a spinner.

The animation connection: your loading delay should match your transition system. If your UI uses 200ms ease-out transitions, set the spinner delay to 200ms. This way, fast responses complete during the natural transition rhythm, and the spinner only appears for genuinely slow operations.

.spinner {
  animation: spin 1s linear infinite;
  animation-delay: 200ms;
  animation-fill-mode: backwards; /* Hidden during delay */
}

The worst pattern is an instantly-visible spinner on every action. A 50ms API call that shows a spinner for one frame creates a distracting flash. The spinner appears, the content loads, the spinner vanishes—all in less time than the user can read "Loading." The delay eliminates this entirely.

Overscroll-behavior and gesture interactions

overscroll-behavior: contain is a craft property that prevents scroll chaining—when a scrollable element reaches its boundary, the parent doesn't start scrolling. This is essential for modals, drawers, and any overlay with scrollable content.

The animation intersection: gesture-driven interactions like swipe-to-dismiss rely on the scroll position of the element. If scroll chaining is active, a user trying to swipe a drawer closed might instead scroll the page behind it. The gesture intent—dismiss—gets captured by the wrong scroll context.

.drawer-content {
  overscroll-behavior: contain;
  touch-action: pan-y; /* Allow vertical scrolling within */
}

This is doubly important for spring-based drag interactions. A drag gesture on a drawer that uses spring physics needs clean scroll isolation—any scroll bleed-through interrupts the spring calculation and produces jerky, broken-feeling motion. The craft (overscroll-behavior) enables the animation (spring drag) to function correctly.

Animation without focus management is incomplete

Consider the full lifecycle of a popover:

  1. User clicks the trigger button.
  2. Popover enters with ease-out, scaling from 95% to 100% with fade.
  3. Focus moves to the first item in the popover.
  4. User selects an option or presses Escape.
  5. Popover exits with ease-in, scaling to 95% with fade.
  6. Focus returns to the trigger button.
  7. Screen reader announces the state change.

Steps 2 and 5 are animation. Steps 3, 6, and 7 are craft. Remove the animation and the popover still works—it appears and disappears instantly, which is fine. Remove the craft and the popover is broken—keyboard users are stranded, screen reader users don't know the popover closed.

The lesson: animation is enhancement. Focus management is infrastructure. You can ship without the animation. You cannot ship without the craft. But the best interfaces get both right, and the two must be coordinated—the focus timing, the ARIA announcements, the scroll containment all need to synchronize with the motion timing.

The coordination pattern

Every animated component needs an interaction audit that answers four questions:

  1. Where does focus go when this appears? The answer should be specific—"the first focusable element" or "the close button" or "the input field."
  2. Where does focus go when this disappears? Almost always: back to the trigger that opened it.
  3. What happens to scroll context? Should the background scroll? Should it lock? Does the component itself scroll?
  4. What does a screen reader hear? At minimum: the component's role, its label, and a state change when it opens or closes.

If you can answer all four and your animation timing accommodates each answer, the intersection is solid. If any answer is "I don't know" or "nothing," the component is incomplete—regardless of how smooth the spring curve looks.

Craft without animation is functional. Animation without craft is decoration. The intersection is where interfaces become complete.