Skip to content

feat(VSlider): add variant "pill" to align with MD3 - #22699

Open
J-Sek wants to merge 13 commits into
devfrom
feat/slider-md3
Open

J-Sek wants to merge 13 commits into
devfrom
feat/slider-md3

Conversation

@J-Sek

@J-Sek J-Sek commented Mar 9, 2026 •

Copy link
Copy Markdown
Contributor
  • new variant prop with value "pill" to apply MD3 styles
  • new size prop for size name x-small | small | medium | large | x-large (only for "pill" variant)
  • new thumb-gap prop
  • verify and clear TODO for indexFromEnd in VSliderTrack.tsx
  • support hue gradient / alpha
  • new styles for thumb label
  • verify/fix ticks ([1], [2])
  • verify vertical

New Sass variables:

  • $slider-thumb-size and $slider-track-size hold 20px/4px defaults that used to be defined as prop defaults
  • $slider-pill-sizes configuration for named sizes
  • $slider-pill-* variables to customize more than just sizing
image

Markup:

<template>
  <v-app theme="dark">
    <v-container max-width="800">
      <v-sheet class="controls mb-8 pa-4" rounded="lg" border>
        <template v-for="control in controls" :key="control.label">
          <span class="text-medium-emphasis text-body-small">{{ control.label }}</span>
          <v-btn-toggle
            v-model="control.model.value"
            :disabled="control.pillOnly && variant !== 'pill'"
            color="primary"
            density="compact"
            variant="outlined"
            divided
            mandatory
          >
            <v-btn
              v-for="item in control.items"
              :key="item.value"
              :text="item.title"
              :value="item.value"
              size="small"
            />
          </v-btn-toggle>
        </template>

        <span class="text-medium-emphasis text-body-small">options</span>
        <v-chip-group v-model="flags" color="primary" column filter multiple>
          <v-chip
            v-for="flag in flagItems"
            :key="flag"
            :text="flag"
            :value="flag"
            size="small"
          />
        </v-chip-group>
      </v-sheet>

      <v-locale-provider :rtl="options.rtl">
        <h3 class="mb-2">Slider</h3>
        <v-row v-for="direction in directions" :key="direction" class="mb-4">
          <v-col
            v-for="(v, i) in values[direction]"
            :key="i"
            class="d-flex flex-column align-center"
            cols="3"
          >
            <v-slider
              v-model="values[direction][i]"
              :direction="direction"
              class="w-100"
              v-bind="{ ...sliderProps, ...iconProps }"
            >
              <template v-if="icons === 'slots'" #prepend-inner>
                <v-icon icon="mdi-volume-low" />
              </template>
              <template v-if="icons === 'slots'" #append-inner>
                <span class="text-caption font-weight-bold">MAX</span>
              </template>
            </v-slider>
            <v-chip size="small">{{ v }}</v-chip>
          </v-col>
        </v-row>

        <h3 class="mb-2 mt-8">Range slider</h3>
        <v-row v-for="direction in directions" :key="direction" class="mb-4">
          <v-col
            v-for="(r, i) in ranges[direction]"
            :key="i"
            class="d-flex flex-column align-center"
            cols="6"
          >
            <v-range-slider
              v-model="ranges[direction][i]"
              :direction="direction"
              class="w-100"
              v-bind="sliderProps"
            />
            <v-chip size="small">{{ r.join(' – ') }}</v-chip>
          </v-col>
        </v-row>

        <h3 class="mb-2 mt-8">Color</h3>
        <v-row
          :style="{
            '--hue-color': `oklch(0.7 0.2 ${hue})`,
            '--gradient-direction': options.rtl !== options.reverse ? 'to left' : 'to right',
          }"
        >
          <v-col
            v-for="space in ['rgb', 'oklch']"
            :key="space"
            class="d-flex flex-column align-center"
            cols="4"
          >
            <v-slider
              v-model="hue"
              :class="['w-100', 'hue-slider', `hue-slider--${space}`]"
              v-bind="{ ...sliderProps, color: undefined, max: 360, step: 1, trackFillColor: 'transparent' }"
            />
            <v-chip size="small">{{ space }} {{ hue }}</v-chip>
          </v-col>
          <v-col class="d-flex flex-column align-center" cols="4">
            <v-slider
              v-model="alpha"
              class="w-100 alpha-slider"
              v-bind="{ ...sliderProps, color: undefined, max: 1, step: 0.01, trackFillColor: 'white' }"
            />
            <v-chip size="small">{{ alpha }}</v-chip>
          </v-col>
        </v-row>

        <h3 class="mb-2 mt-8">Custom track &amp; thumb size</h3>
        <v-row>
          <v-col
            v-for="(custom, i) in customSizes"
            :key="i"
            class="d-flex flex-column align-center"
            cols="4"
          >
            <v-slider
              v-model="customValues[i]"
              class="w-100"
              v-bind="{ ...sliderProps, ...custom }"
            />
            <v-chip size="small">track {{ custom.trackSize }} · thumb {{ custom.thumbSize }}</v-chip>
          </v-col>
        </v-row>
        <h3 class="mb-2 mt-8">Inside other components</h3>
        <v-defaults-provider
          :defaults="{
            VSlider: { variant: 'pill' },
            VMediaProgressBar: { variant: 'pill' },
            VMediaVolume: { VSlider: { trackSize: 6, thumbSize: 20, thumbGap: 2 } }
          }"
        >
          <div class="d-flex flex-wrap align-center ga-6">
            <v-color-picker model-value="#ab2" />
            <v-audio
              :gap="[4, 8]"
              actions="play time progress volume"
              class="bg-surface rounded-pill border pa-1"
              max-width="360"
              src="https://cdn.vuetifyjs.com/docs/images/components/v-audio/03.mp3"
            />
          </div>
        </v-defaults-provider>
      </v-locale-provider>
    </v-container>
  </v-app>
</template>

<script setup>
  import { VMediaVolume } from '@/labs/VMediaVolume'
  import { computed, reactive, shallowRef } from 'vue'

  const directions = ['horizontal', 'vertical']
  const values = reactive({
    horizontal: [20, 40, 60, 80],
    vertical: [20, 40, 60, 80],
  })
  const ranges = reactive({
    horizontal: [[10, 40], [30, 70]],
    vertical: [[10, 40], [30, 70]],
  })

  const hue = shallowRef(200)
  const alpha = shallowRef(0.6)

  const customSizes = [
    { trackSize: 4, thumbSize: 16 },
    { trackSize: 4, thumbSize: 24 },
    { trackSize: 8, thumbSize: 32 },
  ]
  const customValues = reactive([30, 50, 70])

  const variant = shallowRef('pill')
  const size = shallowRef('x-small')
  const icons = shallowRef('none')
  const tickStep = shallowRef(0)

  const controls = [
    { label: 'variant', model: variant, items: ['default', 'pill'].map(v => ({ title: v, value: v })) },
    {
      label: 'size',
      model: size,
      pillOnly: true,
      items: [
        { title: 'xs · 16', value: 'x-small' },
        { title: 's · 24', value: 'small' },
        { title: 'm · 40', value: 'medium' },
        { title: 'l · 56', value: 'large' },
        { title: 'xl · 96', value: 'x-large' },
      ],
    },
    { label: 'inner icons', model: icons, pillOnly: true, items: ['none', 'start', 'end', 'both', 'slots'].map(v => ({ title: v, value: v })) },
    {
      label: 'ticks',
      model: tickStep,
      items: [
        { title: 'off', value: 0 },
        { title: 'every 25', value: 25 },
        { title: 'every 5', value: 5 },
      ],
    },
  ]

  const flagItems = ['color', 'thumb-label', 'reverse', 'rtl', 'disabled']
  const flags = shallowRef(['color'])
  const options = computed(() => Object.fromEntries(flagItems.map(f => [f, flags.value.includes(f)])))

  const iconProps = computed(() => ({
    prependInnerIcon: ['start', 'both'].includes(icons.value) ? 'mdi-volume-low' : undefined,
    appendInnerIcon: ['end', 'both'].includes(icons.value) ? 'mdi-volume-high' : undefined,
  }))

  const sliderProps = computed(() => {
    const step = tickStep.value
    const ticks = step && Object.fromEntries(
      Array.from({ length: 100 / step + 1 }, (_, i) => i * step)
        .map(v => [v, v % 25 ? '' : String(v)])
    )

    return {
      step: 1,
      color: options.value.color ? 'primary' : undefined,
      size: size.value,
      variant: variant.value,
      reverse: options.value.reverse,
      disabled: options.value.disabled,
      thumbLabel: options.value['thumb-label'] ? 'always' : undefined,
      ...(step && { step, ticks, showTicks: 'always' }),
    }
  })
</script>

<style>
  .controls {
    display: grid;
    grid-template-columns: auto 1fr;
    align-items: center;
    gap: 12px 16px;
  }

  .hue-slider--rgb {
    --hue-gradient: #F00 0%, #FF0 16.66%, #0F0 33.33%, #0FF 50%, #00F 66.66%, #F0F 83.33%, #F00 100%;
  }

  .hue-slider--oklch {
    --hue-gradient: oklch(0.7 0.2 0), oklch(0.7 0.2 360);
  }

  .hue-slider--rgb :is(.v-slider-track__background, .v-slider-track__fill) {
    background-image: linear-gradient(var(--gradient-direction), var(--hue-gradient));
  }

  .hue-slider--oklch :is(.v-slider-track__background, .v-slider-track__fill) {
    background-image: linear-gradient(var(--gradient-direction) in oklch longer hue, var(--hue-gradient));
  }

  .alpha-slider :is(.v-slider-track__background, .v-slider-track__fill) {
    background-image: linear-gradient(var(--gradient-direction), transparent, var(--hue-color)), url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Crect width='8' height='8' fill='white'/%3E%3Cpath d='M0 0h4v4H0zM4 4h4v4H4z' fill='%23666'/%3E%3C/svg%3E");
    background-size: var(--v-slider-track-length, 100%) 100%, 8px 8px;
    background-repeat: no-repeat, repeat;
  }

  .v-slider:not(.v-slider--variant-pill):is(.hue-slider, .alpha-slider) .v-slider-track__fill {
    display: none;
  }

  .v-slider.v-input--vertical > .v-input__control {
    min-height: 180px;
  }
</style>

@J-Sek J-Sek self-assigned this Mar 9, 2026
@J-Sek
J-Sek marked this pull request as draft March 9, 2026 19:52
@J-Sek
J-Sek changed the base branch from master to dev March 10, 2026 22:39
@J-Sek
J-Sek force-pushed the dev branch 2 times, most recently from 5b257fd to 86800d9 Compare March 29, 2026 00:37
@J-Sek J-Sek added this to the v4.1.0 milestone Apr 28, 2026
@J-Sek J-Sek modified the milestones: v4.1.0, v4.2.0 May 18, 2026
@J-Sek J-Sek removed their assignment Aug 18, 2026
@J-Sek J-Sek modified the milestones: v4.2.0, v4.3.0 Sep 1, 2026
J-Sek added 2 commits October 16, 2026 15:34
| Conflicts:
|	packages/vuetify/src/components/VSlider/VSliderThumb.tsx
|	packages/vuetify/src/components/VSlider/VSliderTrack.tsx
|	packages/vuetify/src/components/VSlider/slider.ts
@J-Sek
J-Sek marked this pull request as ready for review October 16, 2026 14:05

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant