Skip to content

feat: respect prefers-reduced-motion #21530

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jul 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/docs/src/examples/v-list/usage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@

const code = computed(() => {
return `<${name}${propsToString(props.value)}>
<v-list-item${propsToString(itemProps.value, 2)}></v-list-item>
<v-list-item${propsToString(itemProps.value, [], 2)}></v-list-item>
</${name}>`
})
</script>
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@

@include tools.elevation($bottom-sheet-elevation)

@media (prefers-reduced-motion: reduce)
transition: none

> .v-card,
> .v-sheet
border-radius: $bottom-sheet-border-radius
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@
transition-property: margin-top, border-radius, border, max-width
border-radius: $expansion-panel-border-radius

@media (prefers-reduced-motion: reduce)
transition-property: border-radius, border

&:not(:first-child)::after
border-top-style: solid
border-top-width: thin
Expand Down Expand Up @@ -124,10 +127,12 @@
outline: none
padding: $expansion-panel-title-padding
position: relative
transition: .3s min-height settings.$standard-easing
width: 100%
justify-content: space-between

@media (prefers-reduced-motion: no-preference)
transition: .3s min-height settings.$standard-easing

@include tools.states('.v-expansion-panel-title__overlay', false)

&--focusable.v-expansion-panel-title--active
Expand Down
14 changes: 10 additions & 4 deletions packages/vuetify/src/components/VField/VField.sass
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,9 @@
transition: $field-transition-timing
transition-property: opacity, transform, width

@media (prefers-reduced-motion: reduce)
transition-property: opacity

.v-field--focused &,
.v-field--persistent-clear &
opacity: 1
Expand All @@ -253,10 +256,12 @@
position: absolute
top: var(--v-input-padding-top)
transform-origin: left center
transition: $field-transition-timing
transition-property: opacity, transform
z-index: 1

@media (prefers-reduced-motion: no-preference)
transition: $field-transition-timing
transition-property: opacity, transform

.v-field--variant-underlined &,
.v-field--variant-plain &
top: calc(var(--v-input-padding-top) + var(--v-field-padding-top))
Expand Down Expand Up @@ -373,7 +378,9 @@
&__end
border: 0 solid currentColor
opacity: var(--v-field-border-opacity)
transition: opacity $field-subtle-transition-timing

@media (prefers-reduced-motion: no-preference)
transition: opacity $field-subtle-transition-timing

&__start
flex: 0 0 $field-control-affixed-padding
Expand Down Expand Up @@ -413,7 +420,6 @@
&::before,
&::after
opacity: var(--v-field-border-opacity)
transition: opacity $field-subtle-transition-timing

@include tools.absolute(true)

Expand Down
3 changes: 2 additions & 1 deletion packages/vuetify/src/components/VField/VField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
EventProp,
genericComponent,
nullifyTransforms,
PREFERS_REDUCED_MOTION,
propsFactory,
standardEasing,
useRender,
Expand Down Expand Up @@ -163,7 +164,7 @@ export const VField = genericComponent<new <T>(
const { textColorClasses, textColorStyles } = useTextColor(color)

watch(isActive, val => {
if (hasFloatingLabel.value) {
if (hasFloatingLabel.value && !PREFERS_REDUCED_MOTION()) {
const el: HTMLElement = labelRef.value!.$el
const targetEl: HTMLElement = floatingLabelRef.value!.$el

Expand Down
3 changes: 3 additions & 0 deletions packages/vuetify/src/components/VMain/VMain.sass
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
padding-top: var(--v-layout-top)
padding-bottom: var(--v-layout-bottom)

@media (prefers-reduced-motion: reduce)
transition: none

&__scroller
max-width: 100%
position: relative
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
@include tools.rounded($navigation-drawer-border-radius)
@include tools.theme($navigation-drawer-theme...)

@media (prefers-reduced-motion: reduce)
transition: none

&--rounded
@include tools.rounded($navigation-drawer-rounded-border-radius)

Expand Down
4 changes: 2 additions & 2 deletions packages/vuetify/src/components/VParallax/VParallax.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { useResizeObserver } from '@/composables/resizeObserver'

// Utilities
import { computed, onBeforeUnmount, ref, watch, watchEffect } from 'vue'
import { clamp, genericComponent, getScrollParent, propsFactory, useRender } from '@/util'
import { clamp, genericComponent, getScrollParent, PREFERS_REDUCED_MOTION, propsFactory, useRender } from '@/util'

// Types
import type { VImgSlots } from '@/components/VImg/VImg'
Expand Down Expand Up @@ -71,7 +71,7 @@ export const VParallax = genericComponent<VImgSlots>()({

let frame = -1
function onScroll () {
if (!isIntersecting.value) return
if (!isIntersecting.value || PREFERS_REDUCED_MOTION()) return

cancelAnimationFrame(frame)
frame = requestAnimationFrame(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { makeThemeProps, provideTheme } from '@/composables/theme'

// Utilities
import { ref, toRef, watchEffect } from 'vue'
import { clamp, convertToUnit, genericComponent, propsFactory, useRender } from '@/util'
import { clamp, convertToUnit, genericComponent, PREFERS_REDUCED_MOTION, propsFactory, useRender } from '@/util'

// Types
import type { PropType } from 'vue'
Expand Down Expand Up @@ -89,7 +89,8 @@ export const VProgressCircular = genericComponent<VProgressCircularSlots>()({
{
'v-progress-circular--indeterminate': !!props.indeterminate,
'v-progress-circular--visible': isIntersecting.value,
'v-progress-circular--disable-shrink': props.indeterminate === 'disable-shrink',
'v-progress-circular--disable-shrink': props.indeterminate &&
(props.indeterminate === 'disable-shrink' || PREFERS_REDUCED_MOTION()),
},
themeClasses.value,
sizeClasses.value,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { VForm } from '@/components/VForm'
import { VListItem } from '@/components/VList'

// Utilities
import { commands, generate, render, screen, userEvent } from '@test'
import { commands, generate, render, screen, userEvent, waitForClickable } from '@test'
import { getAllByRole } from '@testing-library/vue'
import { cloneVNode, nextTick, ref } from 'vue'

Expand Down Expand Up @@ -56,13 +56,11 @@ describe('VSelect', () => {
expect(element).not.toHaveClass('v-select--active-menu')

await userEvent.click(menuIcon)
await commands.waitStable('.v-list')
expect(screen.queryAllByCSS('.v-list-item')).toHaveLength(2)
await expect.poll(() => screen.queryAllByCSS('.v-list-item')).toHaveLength(2)
expect(element).toHaveClass('v-select--active-menu')

await userEvent.click(menuIcon)
await commands.waitStable('.v-list')
expect(screen.queryAllByCSS('.v-list-item')).toHaveLength(0)
await expect.poll(() => screen.queryAllByCSS('.v-list-item')).toHaveLength(0)
expect(element).not.toHaveClass('v-select--active-menu')
})

Expand Down Expand Up @@ -153,7 +151,7 @@ describe('VSelect', () => {
await expect(screen.findAllByRole('option', { selected: true })).resolves.toHaveLength(2)

const option = screen.getAllByRole('option')[2]
await commands.waitStable('.v-list')
await waitForClickable(option)
await userEvent.click(option)
expect(selectedItems.value).toStrictEqual(['California', 'Colorado', 'Florida'])

Expand Down Expand Up @@ -204,8 +202,9 @@ describe('VSelect', () => {

await userEvent.click(element)
await expect(screen.findAllByRole('option', { selected: true })).resolves.toHaveLength(2)
await commands.waitStable('.v-list')
await userEvent.click(screen.getAllByRole('option')[2])
const option = screen.getAllByRole('option')[2]
await waitForClickable(option)
await userEvent.click(option)
expect(selectedItems.value).toStrictEqual([
{
title: 'Item 1',
Expand Down Expand Up @@ -280,6 +279,7 @@ describe('VSelect', () => {
expect(element).toHaveTextContent('Item 1')
expect(element).toHaveTextContent('Item 2')

await waitForClickable(options[0])
await userEvent.click(options[0])
expect(selectedItems.value).toStrictEqual([{
text: 'Item 2',
Expand Down Expand Up @@ -484,6 +484,7 @@ describe('VSelect', () => {
expect(options).toHaveLength(2)
expect(options[0]).toHaveTextContent('Item 2')

await waitForClickable(options[0])
await userEvent.click(options[0])
expect(selectedItem.value).toStrictEqual({ text: 'Item 2', id: 'item2' })
expect(screen.queryAllByRole('option', { selected: true })).toHaveLength(0)
Expand Down
4 changes: 4 additions & 0 deletions packages/vuetify/src/components/VSnackbar/VSnackbar.sass
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
&--end
justify-content: flex-end

@include tools.layer('transitions')
.v-snackbar-transition
&-enter-active,
&-leave-active
Expand All @@ -96,6 +97,9 @@
&-enter-active
transition-property: opacity, transform

@media (prefers-reduced-motion: reduce)
transition-property: opacity

&-enter-from
opacity: 0
transform: scale($snackbar-transition-scale)
Expand Down
4 changes: 2 additions & 2 deletions packages/vuetify/src/components/VSparkline/VBarline.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Utilities
import { computed, useId } from 'vue'
import { makeLineProps } from './util/line'
import { genericComponent, getPropertyFromItem, propsFactory, useRender } from '@/util'
import { genericComponent, getPropertyFromItem, PREFERS_REDUCED_MOTION, propsFactory, useRender } from '@/util'

// Types
export type VBarlineSlots = {
Expand Down Expand Up @@ -159,7 +159,7 @@ export const VBarline = genericComponent<VBarlineSlots>()({
rx={ smooth.value }
ry={ smooth.value }
>
{ props.autoDraw && (
{ props.autoDraw && !PREFERS_REDUCED_MOTION() && (
<>
<animate
attributeName="y"
Expand Down
4 changes: 2 additions & 2 deletions packages/vuetify/src/components/VSparkline/VTrendline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { computed, nextTick, ref, useId, watch } from 'vue'
import { makeLineProps } from './util/line'
import { genPath as _genPath } from './util/path'
import { genericComponent, getPropertyFromItem, propsFactory, useRender } from '@/util'
import { genericComponent, getPropertyFromItem, PREFERS_REDUCED_MOTION, propsFactory, useRender } from '@/util'

// Types
export type VTrendlineSlots = {
Expand Down Expand Up @@ -119,7 +119,7 @@ export const VTrendline = genericComponent<VTrendlineSlots>()({
watch(() => props.modelValue, async () => {
await nextTick()

if (!props.autoDraw || !path.value) return
if (!props.autoDraw || !path.value || PREFERS_REDUCED_MOTION()) return

const pathRef = path.value
const length = pathRef.getTotalLength()
Expand Down
3 changes: 3 additions & 0 deletions packages/vuetify/src/components/VToolbar/VToolbar.sass
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
transition-property: height, width, transform, max-width, left, right, top, bottom, box-shadow
width: 100%

@media (prefers-reduced-motion: reduce)
transition-property: box-shadow

@include tools.border($toolbar-border...)
@include tools.elevation($toolbar-elevation)
@include tools.rounded($toolbar-border-radius)
Expand Down
3 changes: 3 additions & 0 deletions packages/vuetify/src/components/VWindow/VWindow.sass
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@
&-leave-active
transition: $window-transition

@media (prefers-reduced-motion: reduce)
transition-duration: 0s

&-leave-from,
&-leave-to
position: absolute !important
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Utilities
import { h, Transition, TransitionGroup } from 'vue'
import { genericComponent, propsFactory } from '@/util'
import { genericComponent, PREFERS_REDUCED_MOTION, propsFactory } from '@/util'

// Types
import type { FunctionalComponent, PropType } from 'vue'
Expand Down Expand Up @@ -95,7 +95,10 @@ export function createJavascriptTransition (
type: String as PropType<'in-out' | 'out-in' | 'default'>,
default: mode,
},
disabled: Boolean,
disabled: {
type: Boolean,
default: PREFERS_REDUCED_MOTION(),
},
group: Boolean,
},

Expand Down
Loading