Skip to content

Every little fish swimming around is a visitor on this page right now, and one of them is you ( ´ ▽ ` )ノ If they get in the way of reading, feel free to turn them off.

Stubborn Slider slider

A slider that never compromises when disabled. (。-`ω´-)

Tech Keywords

NameDescription
Anime.jsLightweight JavaScript animation library
Pointer EventsDetects pointer movement, clicks, hovers, and more, providing coordinates and target information
Physics SimulationSimulates real-world physics such as gravity, collisions, and velocity
CSS AnimationImplemented with CSS transition and animation
JS AnimationJavaScript-driven animation for more complex, precise control; popular libraries include GSAP and anime.js
SVGGraphics format usable in the DOM, great for complex shapes and animation; combined with Vue v-bind it enables data-driven effects

Usage Examples

Basic Usage

When the state is disabled, the more you drag the handle, the longer and tighter it gets. ᕕ( ゚ ∀。)ᕗ

Current Value: 50
View example source code
vue
<template>
  <div class="mx-auto max-w-[20rem] w-full flex flex-col gap-4">
    <base-checkbox
      v-model="disabled"
      class="example-ctrl w-full"
      :label="t('disabled')"
    />

    <div class="flex flex-col flex-1 justify-center">
      {{ t('currentValue') }} {{ Math.floor(value) }}

      <slider-stubborn
        v-model="value"
        :disabled="disabled"
        :max-thumb-length="thumbLength"
        class="w-full"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
import { useWindowSize } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseCheckbox from '../../base-checkbox.vue'
import SliderStubborn from '../slider-stubborn.vue'

const { width, height } = useWindowSize()

const { t } = useI18n()
const disabled = ref(false)
const value = ref(50)

const thumbLength = computed(() =>
  Math.min(width.value, height.value) / 3,
)
</script>

Component Props

Styles can be freely adjusted.

Color:
View example source code
vue
<template>
  <div class="mx-auto max-w-[24rem] w-full flex flex-col gap-10 py-10">
    <div class="mb-10 flex items-center gap-1">
      <base-input
        v-model.number="thumbSize"
        type="range"
        :label="`${t('size')}: ${thumbSize}`"
        class="flex-1"
        :min="10"
        :step="1"
        :max="80"
      />

      <div class="flex-1">
        <div class="text-sm font-bold">
          {{ t('color') }}
        </div>
        <input
          v-model="thumbColor"
          type="color"
          class="h-[40px] w-full"
        >
      </div>
    </div>

    <slider-stubborn
      v-model="value"
      disabled
      :max-thumb-length="thumbMaxLength / 4"
      :thumb-color="thumbColor"
      :thumb-size="thumbSize"
      class="z-[999] w-full"
    />
  </div>
</template>

<script setup lang="ts">
import { useWindowSize } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseInput from '../../../components/base-input.vue'
import SliderStubborn from '../slider-stubborn.vue'

const { width, height } = useWindowSize()

const { t } = useI18n()
const thumbSize = ref(40)
const thumbColor = ref('#FF639B')
const value = ref(50)

const thumbMaxLength = computed(() =>
  Math.min(width.value, height.value),
)
</script>

Plan Selection

Disable the slider for a specific range to emphasize the disabled effect.

Select Your Plan
Basic Fishbowl
1 Just Right
Premium Fishbowl
3~6 Full
Recommended Plan
Luxury Ocean
Choose Your Own
Select Fish Count: 5
View example source code
vue
<template>
  <div class="mx-auto max-w-[28rem] w-full flex flex-col gap-4 py-10">
    <div class="flex flex-col flex-1 justify-center gap-4">
      <div class="text-lg font-bold opacity-90">
        {{ t('selectPlan') }}
      </div>

      <div class="mb-10 flex gap-4">
        <div
          class="card transform rounded-lg from-gray-400 to-gray-500 bg-gradient-to-bl p-1 shadow-md transition-all hover:shadow-lg hover:-translate-y-0.5"
          :class="{ ' border-[0.3rem]': plan === 'basic' }"
          @click="plan = 'basic'"
        >
          <div class="text-lg font-bold md:text-2xl">
            {{ t('basicFishbowl') }}
          </div>

          <div class="mt-1 text-xs opacity-90 md:text-sm">
            {{ t('basicFishbowlDescription') }}
          </div>
        </div>

        <div
          class="card transform border-2 border-indigo-200 rounded-lg from-blue-400 to-indigo-500 bg-gradient-to-bl p-1 shadow-lg transition-all hover:shadow-xl hover:-translate-y-1"
          :class="{ ' border-[0.3rem]': plan === 'premium' }"
          @click="plan = 'premium'"
        >
          <div class="text-lg font-bold md:text-2xl">
            {{ t('premiumFishbowl') }}
          </div>

          <div class="mt-1 text-xs opacity-90 md:text-sm">
            {{ t('premiumFishbowlDescription') }}
          </div>

          <div
            class="absolute right-0 top-0 translate-x-2 transform rounded-bl-lg rounded-tr-lg bg-yellow-400 px-2 py-1 shadow-sm -translate-y-2"
          >
            <div class="text-xs text-red-900 font-semibold md:text-sm">
              {{ t('recommendedPlan') }}
            </div>
          </div>
        </div>

        <div
          class="card transform border-2 border-pink-200 rounded-lg from-purple-500 to-pink-500 bg-gradient-to-bl p-1 shadow-xl transition-all hover:shadow-2xl hover:-translate-y-1.5"
          :class="{ ' border-[0.3rem]': plan === 'luxury' }"
          @click="plan = 'luxury'"
        >
          <div class="text-lg font-bold md:text-2xl">
            {{ t('luxuryOcean') }}
          </div>

          <div class="mt-1 text-xs opacity-90 md:text-sm">
            {{ t('luxuryOceanDescription') }}
          </div>
        </div>
      </div>

      <div class="text-lg font-bold opacity-90">
        {{ t('selectFishCount') }} {{ Math.floor(sliderValue) }}
      </div>

      <slider-stubborn
        v-model="sliderValue"
        v-bind="disabledParams"
        :min="0"
        :max="10"
        :step="0.1"
        :max-thumb-length="thumbLength"
        :thumb-size="40"
        class="w-full py-4"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
import type { ComponentProps } from 'vue-component-type-helpers'
import { useWindowSize } from '@vueuse/core'
import { pipe } from 'remeda'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SliderStubborn from '../slider-stubborn.vue'

type Props = ComponentProps<typeof SliderStubborn>

type Plan = 'basic' | 'premium' | 'luxury'

const { t } = useI18n()
const { width, height } = useWindowSize()

const plan = ref<Plan>('premium')
const sliderValue = ref(5)

const planRangleMap: Record<Plan, [number, number]> = {
  basic: [1, 1],
  premium: [3, 6],
  luxury: [-1, 11],
}
const disabledParams = computed<
  Pick<Props, 'minDisabled' | 'maxDisabled'>
>(() => pipe(
  planRangleMap[plan.value],
  ([min, max]) => ({
    minDisabled: min,
    maxDisabled: max,
  }),
))

const thumbLength = computed(() =>
  Math.min(width.value, height.value) / 3,
)
</script>

<style lang="sass" scoped>
.card
  display: flex
  flex-direction: column
  justify-content: center
  align-items: center
  aspect-ratio: 1 / 1.3
  flex: 1
  transition-duration: 200ms
  color: white
  cursor: pointer
</style>

Disabled

Turn on disabled and the value is locked, no matter how hard you pull the handle. (。-`ω´-)

Rating3 / 5
View example source code
vue
<template>
  <div class="example-wrap relative w-full flex justify-center">
    <div class="max-w-[20rem] w-full flex flex-col gap-4 py-6">
      <div class="flex items-baseline justify-between">
        <span class="text-sm op-60">{{ t('score') }}</span>
        <span class="text-2xl font-bold">{{ value }} / 5</span>
      </div>

      <slider-stubborn
        v-model="value"
        class="w-full"
        :disabled="confirmed"
        :max="5"
        :max-thumb-length="thumbLength"
      />

      <base-checkbox
        v-model="confirmed"
        class="text-sm"
        :label="t('confirmation')"
      />

      <div class="mt-3 flex justify-center">
        <base-btn
          :label="t('submit')"
          :disabled="!confirmed"
          ignore-click
          @click="isSubmitted = true"
        />
      </div>
    </div>

    <transition name="opacity">
      <div
        v-if="isSubmitted"
        class="absolute inset-0 z-[40] flex flex-col items-center justify-center gap-6 rounded-xl bg-slate-600 bg-opacity-90 text-white"
        @click="reset"
      >
        <span class="text-xl tracking-wide">
          {{ t('submitted', { value }) }}
        </span>

        <span class="cursor-pointer text-xs">
          {{ t('retry') }}
        </span>
      </div>
    </transition>
  </div>
</template>

<script setup lang="ts">
import { useWindowSize } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseBtn from '../../base-btn.vue'
import BaseCheckbox from '../../base-checkbox.vue'
import SliderStubborn from '../slider-stubborn.vue'

const { t } = useI18n()
const { width, height } = useWindowSize()

const value = ref(3)
/** 確認後評分定案,握把還拉得長,但數值動不了 */
const confirmed = ref(false)
const isSubmitted = ref(false)

const thumbLength = computed(() =>
  Math.min(width.value, height.value) / 3,
)

function reset() {
  isSubmitted.value = false
  confirmed.value = false
}
</script>

<style lang="sass" scoped>
.opacity-enter-active, .opacity-leave-active
  transition-duration: 0.4s
.opacity-enter-from, .opacity-leave-to
  opacity: 0 !important
</style>

How It Works

Uses an SVG path to create the stretching and bending elastic effect.

For a detailed explanation, see this article.

Warning! Σ(ˊДˋ;)

Do not set overflow to hidden, otherwise the handle will be clipped when it stretches.

Source Code

API

Props

interface Props {
  modelValue: number;
  disabled?: boolean;
  /** 小於此數值也會有 disabled 效果 */
  minDisabled?: number;
  /** 大於此數值也會有 disabled 效果 */
  maxDisabled?: number;
  min?: number;
  max?: number;
  step?: number;
  /** 握把被拉長的最大長度 */
  maxThumbLength?: number;
  thumbSize?: number;
  thumbColor?: string;
  trackClass?: string;
}

Emits

const emit = defineEmits<{
  'update:modelValue': [value: Props['modelValue']];
}>()

v0.88.1