From 513d61d31094d51a9cae4c6ce48ba426d88fe742 Mon Sep 17 00:00:00 2001 From: Aditya Duggirala <311752106+adityalenova@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:02:16 +0530 Subject: [PATCH] Checkbox: Respect prefers-reduced-motion for the checkmark animation The clip-path wipe on the checkmark is decorative - whether the mark is shown is driven by visibility - so move the animation and the 230ms hide delay behind @media (prefers-reduced-motion: no-preference), the same way Skeleton and SegmentedControl opt in to motion. With motion reduced the mark is simply present when checked or indeterminate. The animated path keeps the declarations it uses today, including the clipped start value that covers the 80ms delay on checkmarkIn, so the rendered end state is unchanged. --- .changeset/checkbox-reduced-motion.md | 5 ++ e2e/components/Checkbox.test.ts | 77 ++++++++++++++++++- .../react/src/Checkbox/Checkbox.module.css | 19 ++++- 3 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 .changeset/checkbox-reduced-motion.md diff --git a/.changeset/checkbox-reduced-motion.md b/.changeset/checkbox-reduced-motion.md new file mode 100644 index 00000000000..916383b84a3 --- /dev/null +++ b/.changeset/checkbox-reduced-motion.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +Checkbox: Respect prefers-reduced-motion for the checkmark animation. diff --git a/e2e/components/Checkbox.test.ts b/e2e/components/Checkbox.test.ts index 68bdc98193f..1b45ca9d12f 100644 --- a/e2e/components/Checkbox.test.ts +++ b/e2e/components/Checkbox.test.ts @@ -1,4 +1,4 @@ -import {test, expect} from '@playwright/test' +import {test, expect, type Page} from '@playwright/test' import {visit} from '../test-helpers/storybook' import {themes} from '../test-helpers/themes' @@ -72,4 +72,79 @@ test.describe('Checkbox', () => { } }) } + + // The checkmark is drawn by the input's `::before`, which `toHaveCSS` cannot + // target, so these read the computed style of the pseudo-element directly. + const describeCheckmark = (page: Page) => + page.locator('[data-component="Checkbox"]').evaluate(element => { + const {animationName, clipPath, visibility, transitionDelay} = getComputedStyle(element, '::before') + return {animationName, clipPath, visibility, transitionDelay} + }) + + test.describe('reduced motion', () => { + // `contextOptions` is what the root Playwright config uses to emulate + // `prefers-reduced-motion`, so it is the option that has to be overridden. + test.use({contextOptions: {reducedMotion: 'reduce'}}) + + test('checkmark appears without the clip-path animation @vrt', async ({page}) => { + await visit(page, { + id: 'components-checkbox--playground', + args: {checked: true}, + }) + + expect(await describeCheckmark(page)).toEqual({ + animationName: 'none', + // Chromium reports a fully un-clipped value as `inset(0px)` + clipPath: 'inset(0px)', + visibility: 'visible', + transitionDelay: '0s', + }) + + await visit(page, { + id: 'components-checkbox-features--indeterminate', + }) + + expect(await describeCheckmark(page)).toEqual({ + animationName: 'none', + clipPath: 'inset(0px)', + visibility: 'visible', + transitionDelay: '0s', + }) + + // Clearing the indeterminate state hides the mark immediately, with no + // delay left over from the animation-out + await page.locator('[data-component="Checkbox"]').click() + + expect(await describeCheckmark(page)).toEqual({ + animationName: 'none', + clipPath: 'inset(16px 0px 0px)', + visibility: 'hidden', + transitionDelay: '0s', + }) + }) + }) + + test.describe('no preference for reduced motion', () => { + test.use({contextOptions: {reducedMotion: 'no-preference'}}) + + test('checkmark keeps its clip-path animation @vrt', async ({page}) => { + await visit(page, { + id: 'components-checkbox-features--indeterminate', + }) + + // Names are scoped by CSS Modules, so only the base name is stable. The + // clip-path itself is not worth asserting here: by the time it can be + // read, the 80ms wipe has already run and landed on the un-clipped value. + const checkmark = await describeCheckmark(page) + expect(checkmark.animationName).toMatch(/checkmarkIn/) + expect(checkmark.visibility).toBe('visible') + + await page.locator('[data-component="Checkbox"]').click() + + const cleared = await describeCheckmark(page) + expect(cleared.animationName).toMatch(/checkmarkOut/) + // The mark is kept visible until the wipe-out has run + expect(cleared.transitionDelay).toBe('0.23s') + }) + }) }) diff --git a/packages/react/src/Checkbox/Checkbox.module.css b/packages/react/src/Checkbox/Checkbox.module.css index 26e48109d40..9ae48547214 100644 --- a/packages/react/src/Checkbox/Checkbox.module.css +++ b/packages/react/src/Checkbox/Checkbox.module.css @@ -13,13 +13,19 @@ content: ''; /* stylelint-disable-next-line primer/colors */ background-color: var(--fgColor-onEmphasis); - transition: visibility 0s linear 230ms; clip-path: inset(var(--base-size-16) 0 0 0); mask-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTIiIGhlaWdodD0iOSIgdmlld0JveD0iMCAwIDEyIDkiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTEuNzgwMyAwLjIxOTYyNUMxMS45MjEgMC4zNjA0MjcgMTIgMC41NTEzMDUgMTIgMC43NTAzMTNDMTIgMC45NDkzMjEgMTEuOTIxIDEuMTQwMTkgMTEuNzgwMyAxLjI4MUw0LjUxODYgOC41NDA0MkM0LjM3Nzc1IDguNjgxIDQuMTg2ODIgOC43NiAzLjk4Nzc0IDguNzZDMy43ODg2NyA4Ljc2IDMuNTk3NzMgOC42ODEgMy40NTY4OSA4LjU0MDQyTDAuMjAxNjIyIDUuMjg2MkMwLjA2ODkyNzcgNS4xNDM4MyAtMC4wMDMzMDkwNSA0Ljk1NTU1IDAuMDAwMTE2NDkzIDQuNzYwOThDMC4wMDM1NTIwNSA0LjU2NjQzIDAuMDgyMzg5NCA0LjM4MDgxIDAuMjIwMDMyIDQuMjQzMjFDMC4zNTc2NjUgNC4xMDU2MiAwLjU0MzM1NSA0LjAyNjgxIDAuNzM3OTcgNC4wMjMzOEMwLjkzMjU4NCA0LjAxOTk0IDEuMTIwOTMgNC4wOTIxNyAxLjI2MzM0IDQuMjI0ODJMMy45ODc3NCA2Ljk0ODM1TDEwLjcxODYgMC4yMTk2MjVDMTAuODU5NSAwLjA3ODk5MjMgMTEuMDUwNCAwIDExLjI0OTUgMEMxMS40NDg1IDAgMTEuNjM5NSAwLjA3ODk5MjMgMTEuNzgwMyAwLjIxOTYyNVoiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo='); mask-size: 75%; mask-repeat: no-repeat; mask-position: center; - animation: checkmarkOut 80ms cubic-bezier(0.65, 0, 0.35, 1) forwards; + + /* The clip-path wipe is decorative, so it is opt-in like every other + animated component (Skeleton, SegmentedControl). */ + @media (prefers-reduced-motion: no-preference) { + /* checked -> unchecked - wait out the wipe-out before hiding the mark */ + transition: visibility 0s linear 230ms; + animation: checkmarkOut 80ms cubic-bezier(0.65, 0, 0.35, 1) forwards; + } } &:checked, @@ -39,7 +45,14 @@ &:checked::before, &:indeterminate::before { - animation: checkmarkIn 80ms cubic-bezier(0.65, 0, 0.35, 1) forwards 80ms; + /* Reduced motion: the mark is simply present, without the wipe */ + clip-path: inset(0 0 0 0); + + @media (prefers-reduced-motion: no-preference) { + /* Keep the mark clipped while `checkmarkIn` waits out its 80ms delay */ + clip-path: inset(var(--base-size-16) 0 0 0); + animation: checkmarkIn 80ms cubic-bezier(0.65, 0, 0.35, 1) forwards 80ms; + } } &:checked:disabled,