Repository: solid-design-system/solid
Pull request #2207: feat: add dialog motion
Author: login: smfonseca
Created: 2025-05-08T17:15:55Z | Merged: 2025-05-20T13:27:27Z

<!-- ## Title: Please consider adding the [skip chromatic] flag to the PR title in case you dont need chromatic testing your changes. -->
## Description:
Closes [#2188](https://github.com/solid-design-system/solid/issues/2188)

@coraliefeil @MartaPintoTeixeira as discussed, I added a different animation for reduced motion. Please also test it. Thank you

## Definition of Reviewable:
<!-- *PR notes: Irrelevant elements should be removed.* -->
- [ ] Documentation is created/updated
- [ ] Migration Guide is created/updated
- [ ] E2E tests (features, a11y, bug fixes) are created/updated
<!-- *If this PR includes a bug fix, an E2E test is necessary to verify the change. If the fix is purely visual, ensuring it is captured within our chromatic screenshot tests is sufficient.* -->
- [ ] Stories (features, a11y) are created/updated
- [ ] relevant tickets are linked



Diff:
diff --git a/.changeset/empty-adults-repair.md b/.changeset/empty-adults-repair.md
new file mode 100644
index 0000000000..4e0a6e593b
--- /dev/null
+++ b/.changeset/empty-adults-repair.md
@@ -0,0 +1,5 @@
+---
+'@solid-design-system/components': minor
+---
+
+Add motion design to `sd-dialog`.
diff --git a/packages/components/src/components/dialog/dialog.ts b/packages/components/src/components/dialog/dialog.ts
index 61ca009b82..b586cf29b9 100644
--- a/packages/components/src/components/dialog/dialog.ts
+++ b/packages/components/src/components/dialog/dialog.ts
@@ -10,6 +10,7 @@ import { ifDefined } from 'lit-html/directives/if-defined.js';
 import { LocalizeController } from '../../utilities/localize';
 import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll';
 import { property, query } from 'lit/decorators.js';
+import { token } from '../../internal/token';
 import { waitForEvent } from '../../internal/event';
 import { watch } from '../../internal/watch';
 import cx from 'classix';
@@ -106,6 +107,10 @@ export default class SdDialog extends SolidElement {
     unlockBodyScrolling(this);
   }
 
+  private get prefersReducedMotion() {
+    return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+  }
+
   private requestClose(source: 'close-button' | 'keyboard' | 'overlay') {
     const sdRequestClose = this.emit('sd-request-close', {
       cancelable: true,
@@ -180,7 +185,9 @@ export default class SdDialog extends SolidElement {
         }
       });
 
-      const panelAnimation = getAnimation(this, 'dialog.show', { dir: this.localize.dir() });
+      const panelAnimation = this.prefersReducedMotion
+        ? getAnimation(this, 'dialog.showReducedMotion', { dir: this.localize.dir() })
+        : getAnimation(this, 'dialog.show', { dir: this.localize.dir() });
       const overlayAnimation = getAnimation(this, 'dialog.overlay.show', { dir: this.localize.dir() });
       await Promise.all([
         animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options),
@@ -195,7 +202,9 @@ export default class SdDialog extends SolidElement {
       this.modal.deactivate();
 
       await Promise.all([stopAnimations(this.dialog), stopAnimations(this.overlay)]);
-      const panelAnimation = getAnimation(this, 'dialog.hide', { dir: this.localize.dir() });
+      const panelAnimation = this.prefersReducedMotion
+        ? getAnimation(this, 'dialog.hideReducedMotion', { dir: this.localize.dir() })
+        : getAnimation(this, 'dialog.hide', { dir: this.localize.dir() });
       const overlayAnimation = getAnimation(this, 'dialog.overlay.hide', { dir: this.localize.dir() });
 
       // Animate the overlay and the panel at the same time. Because animation durations might be different, we need to
@@ -352,18 +361,28 @@ export default class SdDialog extends SolidElement {
 
 setDefaultAnimation('dialog.show', {
   keyframes: [
-    { opacity: 0, scale: 0.8 },
-    { opacity: 1, scale: 1 }
+    { opacity: 0, transform: 'translate(-50%, 100%)' },
+    { opacity: 1, transform: 'translate(0, 0)' }
   ],
-  options: { duration: 250, easing: 'ease' }
+  options: { duration: (token('sd-duration-medium') as number) || 300, easing: 'ease-in-out' }
+});
+
+setDefaultAnimation('dialog.showReducedMotion', {
+  keyframes: [{ opacity: 0 }, { opacity: 1 }],
+  options: { duration: (token('sd-duration-medium') as number) || 300, easing: 'ease-in-out', reducedMotion: 'allow' }
 });
 
 setDefaultAnimation('dialog.hide', {
   keyframes: [
-    { opacity: 1, scale: 1 },
-    { opacity: 0, scale: 0.8 }
+    { opacity: 1, transform: 'translate(0, 0)' },
+    { opacity: 0, transform: 'translate(50%, -100%)' }
   ],
-  options: { duration: 250, easing: 'ease' }
+  options: { duration: (token('sd-duration-fast') as number) || 150, easing: 'ease-in-out' }
+});
+
+setDefaultAnimation('dialog.hideReducedMotion', {
+  keyframes: [{ opacity: 1 }, { opacity: 0 }],
+  options: { duration: (token('sd-duration-fast') as number) || 150, easing: 'ease-in-out', reducedMotion: 'allow' }
 });
 
 setDefaultAnimation('dialog.denyClose', {
@@ -373,12 +392,12 @@ setDefaultAnimation('dialog.denyClose', {
 
 setDefaultAnimation('dialog.overlay.show', {
   keyframes: [{ opacity: 0 }, { opacity: 1 }],
-  options: { duration: 250 }
+  options: { duration: (token('sd-duration-medium') as number) || 300, easing: 'ease-in-out', reducedMotion: 'allow' }
 });
 
 setDefaultAnimation('dialog.overlay.hide', {
   keyframes: [{ opacity: 1 }, { opacity: 0 }],
-  options: { duration: 250 }
+  options: { duration: (token('sd-duration-fast') as number) || 150, easing: 'ease-in-out', reducedMotion: 'allow' }
 });
 
 declare global {
diff --git a/packages/components/src/internal/animate.ts b/packages/components/src/internal/animate.ts
index bc86c5750c..a5b7ca7626 100644
--- a/packages/components/src/internal/animate.ts
+++ b/packages/components/src/internal/animate.ts
@@ -1,15 +1,20 @@
 /**
  * Animates an element using keyframes. Returns a promise that resolves after the animation completes or gets canceled.
  */
-export function animateTo(el: HTMLElement, keyframes: Keyframe[], options?: KeyframeAnimationOptions) {
+interface ExtendedKeyframeAnimationOptions extends KeyframeAnimationOptions {
+  reducedMotion?: 'allow';
+}
+
+export function animateTo(el: HTMLElement, keyframes: Keyframe[], options?: ExtendedKeyframeAnimationOptions) {
   return new Promise(resolve => {
     if (options?.duration === Infinity) {
       throw new Error('Promise-based animations must be finite.');
     }
 
+    const reducedMotion = prefersReducedMotion();
     const animation = el.animate(keyframes, {
       ...options,
-      duration: prefersReducedMotion() ? 0 : options!.duration
+      duration: reducedMotion && options?.reducedMotion !== 'allow' ? 0 : options!.duration
     });
 
     animation.addEventListener('cancel', resolve, { once: true });
diff --git a/packages/components/src/internal/token.ts b/packages/components/src/internal/token.ts
index bdfeb2b8fa..1594a151b5 100644
--- a/packages/components/src/internal/token.ts
+++ b/packages/components/src/internal/token.ts
@@ -3,7 +3,7 @@ const TOKENS = {
 } as const;
 
 const processors: Record<string, (value: string) => string | number> = {
-  duration: (value: string): number => (value.startsWith('s') ? parseFloat(value) * 1000 : parseFloat(value))
+  duration: (value: string): number => (value.endsWith('ms') ? parseFloat(value) : parseFloat(value) * 1000)
 };
 
 export const token = (name: string): string | number | null => {
diff --git a/packages/components/src/utilities/animation-registry.ts b/packages/components/src/utilities/animation-registry.ts
index 35ae85ac04..34e0c69f8a 100644
--- a/packages/components/src/utilities/animation-registry.ts
+++ b/packages/components/src/utilities/animation-registry.ts
@@ -1,7 +1,11 @@
+interface ExtendedKeyframeAnimationOptions extends KeyframeAnimationOptions {
+  reducedMotion?: 'allow';
+}
+
 export interface ElementAnimation {
   keyframes: Keyframe[];
   rtlKeyframes?: Keyframe[];
-  options?: KeyframeAnimationOptions;
+  options?: ExtendedKeyframeAnimationOptions;
 }
 
 export interface ElementAnimationMap {


Reviews:
user: login: paulovareiro29
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T10:44:30Z
user: login: smfonseca
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-19T11:41:53Z
user: login: smfonseca
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-19T11:42:13Z
user: login: MartaPintoTeixeira
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T16:01:46Z
user: login: auroraVasconcelos
body: LGTM
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T16:34:32Z
user: login: paulovareiro29
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T09:14:09Z

Review comments:
diff_hunk: @@ -104,6 +109,15 @@ export default class SdDialog extends SolidElement {
   disconnectedCallback() {
     super.disconnectedCallback();
     unlockBodyScrolling(this);
+    window
+      .matchMedia('(prefers-reduced-motion: reduce)')
+      .removeEventListener('change', this.updatePrefersReducedMotion.bind(this));
+  }
+
+  private prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+
+  private updatePrefersReducedMotion() {
+    this.prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
   }
path: packages/components/src/components/dialog/dialog.ts
user: login: paulovareiro29
body: Can't this become a getter instead?
It will become more readable and will remove the event listener.

```suggestion
  }

  private get prefersReducedMotion() {
    return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  }
```
created_at: 2025-05-14T10:42:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -373,12 +401,12 @@ setDefaultAnimation('dialog.denyClose', {
 
 setDefaultAnimation('dialog.overlay.show', {
   keyframes: [{ opacity: 0 }, { opacity: 1 }],
-  options: { duration: 250 }
+  options: { duration: (token('sd-duration-medium') as number) || 300, easing: 'ease-in-out' }
 });
path: packages/components/src/components/dialog/dialog.ts
user: login: paulovareiro29
body: With `reduced motion`, the overlay is not transitioning on the opacity. I believe we can allow it, even with reduced motion, since it is just an opacity change.

Let me know what you think
created_at: 2025-05-14T10:44:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -104,6 +109,15 @@ export default class SdDialog extends SolidElement {
   disconnectedCallback() {
     super.disconnectedCallback();
     unlockBodyScrolling(this);
+    window
+      .matchMedia('(prefers-reduced-motion: reduce)')
+      .removeEventListener('change', this.updatePrefersReducedMotion.bind(this));
+  }
+
+  private prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+
+  private updatePrefersReducedMotion() {
+    this.prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
   }
path: packages/components/src/components/dialog/dialog.ts
user: login: smfonseca
body: This is a very good point.
It's updated.
created_at: 2025-05-19T11:41:53Z
author_association: COLLABORATOR
diff_hunk: @@ -373,12 +401,12 @@ setDefaultAnimation('dialog.denyClose', {
 
 setDefaultAnimation('dialog.overlay.show', {
   keyframes: [{ opacity: 0 }, { opacity: 1 }],
-  options: { duration: 250 }
+  options: { duration: (token('sd-duration-medium') as number) || 300, easing: 'ease-in-out' }
 });
path: packages/components/src/components/dialog/dialog.ts
user: login: smfonseca
body: Definitely!
created_at: 2025-05-19T11:42:13Z
author_association: COLLABORATOR

PR comments:
user: login: changeset-bot[bot]
created_at: 2025-05-08T17:15:58Z
body: ###  🦋  Changeset detected

Latest commit: dc86b0e9271425f8133edc4aef18ec6cdaf24d26

**The changes in this PR will be included in the next version bump.**



Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/solid-design-system/solid/new/feat/dialog-motion?filename=.changeset/rich-suns-drop.md&value=---%0A%22%40fake-scope%2Ffake-pkg%22%3A%20patch%0A---%0A%0Afeat%3A%20add%20dialog%20motion%0A)


author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-08T17:19:33Z
body: 🚀 Storybook has been deployed for branch `feat_dialog-motion`
- [View Storybook](https://solid-design-system.github.io/solid/feat_dialog-motion)
author_association: CONTRIBUTOR