Animating a Share Sheet with Reduced-Motion Support

This guide is part of Building a Custom Share Sheet, which itself is part of Permission Flows and Progressive Enhancement.

A share sheet that slides up feels considered. The same slide, for someone with a vestibular disorder, can cause real physical discomfort — and for everyone it delays a response to a deliberate tap. Both problems are solved by the same discipline: animate in CSS, keep the duration short, and let prefers-reduced-motion switch the motion off without changing a line of behaviour.

Feature Detection Gate

Animation is presentation, so nothing about it may affect whether the sheet works. Detection here is only for the rare case where a script sequences something.

// motion.js
export function prefersReducedMotion() {
  return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true;
}

export function onMotionPreferenceChange(callback) {
  const query = window.matchMedia?.('(prefers-reduced-motion: reduce)');
  if (!query) return () => {};
  const handler = (event) => callback(event.matches);
  query.addEventListener('change', handler);
  return () => query.removeEventListener('change', handler);
}

Read the preference live rather than caching it at startup. Users change the setting mid-session — often precisely because something on the page made them uncomfortable — and a cached value ignores them until reload.

Step 1 — Drive the Entrance from CSS

The dialog’s own [open] state is enough to animate against, so no JavaScript needs to orchestrate anything.

.share-sheet {
  opacity: 0;
  transform: translateY(12px) scale(0.98);
  transition:
    opacity 160ms ease-out,
    transform 160ms ease-out,
    display 160ms allow-discrete,
    overlay 160ms allow-discrete;
}

.share-sheet[open] {
  opacity: 1;
  transform: none;
}

/* Entry values: applied for one frame as the dialog enters the top layer. */
@starting-style {
  .share-sheet[open] {
    opacity: 0;
    transform: translateY(12px) scale(0.98);
  }
}

.share-sheet::backdrop {
  background: rgb(0 0 0 / 0);
  transition: background 160ms ease-out, display 160ms allow-discrete, overlay 160ms allow-discrete;
}
.share-sheet[open]::backdrop { background: rgb(0 0 0 / 0.45); }
@starting-style {
  .share-sheet[open]::backdrop { background: rgb(0 0 0 / 0); }
}

Two modern pieces make this work. @starting-style supplies the “before” values for an element that has just been inserted into the top layer — without it, the dialog appears already at its final state and the entrance never runs. And transition-behavior: allow-discrete on display and overlay keeps the element painted while it exits, which is the fix for the near-universal complaint that closing animations do not play.

Open and Close Transitions in the Top Layer The open sequence runs from showModal through the starting-style frame to the final state. The close sequence runs from close through a period where allow-discrete keeps the element painted, to removal from the top layer. Without those two features the entrance is skipped and the exit is invisible. Opening showModal() @starting-style frame opacity 0 · translateY 12px [open] final state — 160 ms without starting-style this is instant Closing close() allow-discrete holds it display + overlay still painted removed from the top layer without allow-discrete this is immediate Neither feature affects behaviour — focus, Escape and inertness are unchanged in every case

Step 2 — Switch Motion Off in One Place

A single media query at the end of the stylesheet covers every animated property, and it applies the moment the user changes the setting.

@media (prefers-reduced-motion: reduce) {
  .share-sheet,
  .share-sheet::backdrop {
    transition-duration: 1ms;      /* not 0 — keeps transitionend firing */
    transform: none;
  }
  .share-sheet[open] { transform: none; }
}

Keeping a 1 ms duration rather than none is a small robustness win: any code that waits for transitionend still receives the event, so a reduced-motion user cannot end up in a state where a promised callback never fires. Removing the transform entirely, rather than shortening it, is the substantive part — motion across the screen is what the preference is about, while the opacity fade is generally considered acceptable.

Step 3 — Never Let Behaviour Depend on the Animation

The failure this prevents is a sheet that opens but cannot be used, because focus was scheduled on an event that never arrived.

// open-sheet.js
export function openSheet(dialog) {
  dialog.showModal();

  // Focus immediately. Do NOT wait for transitionend — with reduced motion,
  // a cancelled transition, or a background tab, it may never fire.
  dialog.querySelector('[data-action="copy"]')?.focus();
}

export function closeSheet(dialog, onClosed) {
  dialog.addEventListener('close', () => onClosed?.(), { once: true });
  dialog.close();     // the exit animation plays via allow-discrete; state is already correct
}

The rule generalises: animation may describe a state change, never gate one. Anything sequenced off transitionend needs a timeout fallback at minimum, and is usually better restructured so no wait exists at all.

Step 4 — Keep the Duration Honest

A share sheet is a direct response to a tap. Above roughly 200 ms the animation stops reading as polish and starts reading as lag — and on a mid-range phone, an over-ambitious entrance competes with the layout work of the sheet itself.

:root {
  --sheet-duration: 160ms;
  --sheet-ease: cubic-bezier(0.2, 0, 0.2, 1);   /* fast out, gentle settle */
}
@media (prefers-reduced-motion: reduce) {
  :root { --sheet-duration: 1ms; }
}

Centralising the duration in a custom property means the reduced-motion override is one line, and every animated element in the sheet stays in step automatically.

Default Motion Versus Reduced Motion Two treatments of the same dialog. The default fades and rises twelve pixels over 160 milliseconds with a dimming backdrop. The reduced-motion treatment removes the movement entirely, keeps a one-millisecond duration so transition events still fire, and reaches an identical final state. Default opacity 0 → 1 translateY 12px → 0 scale 0.98 → 1 backdrop dims to 45% 160 ms · fast-out ease prefers-reduced-motion opacity 0 → 1 (acceptable) no translate — movement removed no scale backdrop still dims 1 ms · transitionend still fires Animate Only What the Compositor Can Handle Opacity and transform are handled by the compositor and stay smooth on mid-range hardware. Height, top and width force layout and paint on every frame, which produces visible jank on exactly the phones where the sheet matters most. compositor-friendly opacity transform: translate / scale no layout, no paint per frame smooth at 160 ms on a mid-range phone forces layout height / width top / left / margin recalculated every frame janky exactly where it is noticed

Failure Modes and Recovery

Symptom Cause Minimal fix
Entrance animation never plays No @starting-style values Add a @starting-style block for [open]
Closing animation invisible close() removes it from the top layer instantly Transition display and overlay with allow-discrete
Sheet opens but nothing is focusable Focus scheduled on transitionend Focus immediately after showModal()
Motion persists after the OS setting changes Preference cached at startup Use the CSS media query, or read it live
Callback never fires with reduced motion Duration set to 0 cancels transitionend Use 1ms instead
Sheet feels sluggish on a mid-range phone Duration too long, or animating layout properties Stay under ~200 ms; animate opacity and transform only

Browser and Platform Caveat

@starting-style and transition-behavior: allow-discrete are available in current Chrome, Edge and Safari, and Firefox has shipped them recently; where they are absent the dialog simply appears and disappears without animation, which is a perfectly acceptable degradation and the reason this approach needs no JavaScript fallback. prefers-reduced-motion is universally supported and, on both iOS and Android, maps to a genuine system accessibility setting that a meaningful number of people have enabled — this is not a rare configuration. Animate only opacity and transform, which the compositor can handle without layout work; animating height or top on a share sheet produces jank on exactly the mid-range hardware where the sheet matters most. None of this changes the accessibility contract described in building an accessible share modal with a focus trap, which holds whether or not any animation runs.

FAQ

Why does my closing animation never appear?

Calling close() removes the dialog from the top layer and sets display: none immediately, so there is nothing left to animate. Transition display and overlay with transition-behavior: allow-discrete so the element stays painted for the duration of the exit.

Should I check prefers-reduced-motion in JavaScript?

Prefer CSS. A media query applies immediately, updates when the user changes the setting, and needs no listener. Read it in JavaScript only when a script genuinely sequences something — and then read it live rather than caching the value at startup.

Does reduced motion mean no animation at all?

It means no large or vestibular motion — sliding, scaling, parallax. A short opacity fade is generally considered acceptable and helps convey that something appeared. What must go is movement across the screen and anything longer than a moment.