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.
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.
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.