Elastic Slider

Most sliders feel passive — you drag and a value changes. The elastic mode makes the component physically respond when you push past its limits. The container stretches outward, then springs back the moment you let go.

Three modes, one component

The slider ships with three interaction personalities, each revealing a different philosophy about how inputs should behave.

Fluid is the default, smooth and continuous, snapping to the nearest step without any theatrics. Stepped adds a tactile pulse on each increment, making discrete values feel physical. Elastic is the interesting one: the container itself becomes a participant. When you drag past the minimum or maximum, it grows toward your cursor and snaps back on release using a spring curve.

The elastic illusion

The trick is separating two concerns that are usually the same: where the cursor is and what the value is.

In elastic mode, the value is always clamped to [min, max]. But the container's width is not. When the cursor goes past the right edge by 30px, the container grows by up to maxExpand pixels (default 10) using a dampening factor of 0.5:

if (clientX > rect.right) {
  elasticSide.value = 'max';
  elasticExpand.value = Math.min((clientX - rect.right) * 0.5, props.maxExpand);
}

The dragStartRect is captured once at mousedown and never updated during the drag. This is critical — if you recalculated the rect on every move, the expanding container would shift the coordinate space and make value math incorrect.

Freezing the rect

const onMouseDown = (e: MouseEvent) => {
  dragStartRect.value = containerRef.value?.getBoundingClientRect() ?? null;
  processMove(e.clientX);
  // ...
};

const rawFromX = (clientX: number): number => {
  const rect = dragStartRect.value ?? containerRef.value?.getBoundingClientRect();
  if (!rect) return props.modelValue;
  const ratio = (clientX - rect.left) / rect.width;
  return props.min + ratio * range.value;
};

The drag-start rect acts as a frozen coordinate system. The container visually expands, but all value math still refers to the original bounds. Without this, dragging past the edge would compress the effective range and make the thumb jump.

The spring-back animation

When the user releases, the expansion doesn't just snap to zero — it animates with a spring curve that has a slight overshoot:

const SPRING = '0.45s cubic-bezier(0.34, 1.56, 0.64, 1)';

The 1.56 and 0.64 control points push past the natural end position before settling. This is the same easing iOS uses for rubber-band scrolling — it communicates resistance, not just movement.

const onRelease = () => {
  if (props.mode === 'elastic' && elasticExpand.value > 0) {
    isSpringBack.value = true;
    requestAnimationFrame(() => {
      elasticExpand.value = 0;
      setTimeout(() => {
        isSpringBack.value = false;
        elasticSide.value = null;
      }, 450);
    });
  }
};

The requestAnimationFrame wrapping elasticExpand.value = 0 ensures the transition starts from the current expanded state rather than cutting straight to zero. The isSpringBack flag keeps the spring transition active until the animation completes.

Fill and thumb stay in sync

One subtle challenge: the fill and the thumb need to use the same position source at all times. During a drag both read from displayValue — the live, clamped visual value. At rest, they both read from props.modelValue. This keeps them perfectly synchronized regardless of which phase we're in.

The fill width formula is worth studying:

width: `calc(${PAD}px + (100% - ${PAD * 2}px) * ${basePct / 100} + 12px)`

PAD (18px) is the container's horizontal padding — the thumb never touches the edges. The + 12px offset makes the fill's right edge land exactly under the thumb center, so there's no visible gap as you drag.

Stepped mode and the pulse

The stepped mode adds something you can't achieve with CSS alone — a pulse on every step boundary. Each time the snapped value changes, a class is briefly applied:

isPulse.value = true;
setTimeout(() => { isPulse.value = false; }, 100);
.sl-thumb--pulse {
  transform: translateX(-50%) scaleY(1.15);
}

100ms is short enough to feel instantaneous but long enough for the eye to catch. The thumb grows slightly taller on each step, turning a value change into a physical sensation.

Full component

<script setup lang="ts">
interface Props {
  modelValue: number;
  min: number;
  max: number;
  step?: number;
  mode?: 'fluid' | 'stepped' | 'elastic';
  label?: string;
  suffix?: string;
  prefix?: string;
  showRange?: boolean;
  stepperEvery?: number;
  maxExpand?: number;
}

const props = withDefaults(defineProps<Props>(), {
  step: 1,
  mode: 'fluid',
  label: '',
  suffix: '',
  prefix: '',
  showRange: false,
  stepperEvery: undefined,
  maxExpand: 10,
});

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

const containerRef = ref<HTMLElement | null>(null);
const isDragging = ref(false);
const isSpringBack = ref(false);
const isPulse = ref(false);
const dragStartRect = ref<DOMRect | null>(null);
const elasticSide = ref<'min' | 'max' | null>(null);
const elasticExpand = ref(0);
const displayValue = ref(props.modelValue);

const PAD = 18;
const SPRING = '0.45s cubic-bezier(0.34, 1.56, 0.64, 1)';

const range = computed(() => props.max - props.min);
const toPercent = (v: number) => ((v - props.min) / range.value) * 100;
const clampVal = (v: number) => Math.max(props.min, Math.min(props.max, v));
const fmt = (v: number) => `${props.prefix}${v}${props.suffix}`;

const snapToStep = (v: number): number => {
  const c = clampVal(v);
  const snapped = Math.round((c - props.min) / props.step) * props.step + props.min;
  return clampVal(parseFloat(snapped.toPrecision(10)));
};

const rawFromX = (clientX: number): number => {
  const rect = dragStartRect.value ?? containerRef.value?.getBoundingClientRect();
  if (!rect) return props.modelValue;
  return props.min + ((clientX - rect.left) / rect.width) * range.value;
};
</script>

<template>
  <div class="sl-wrap">
    <div
      ref="containerRef"
      class="sl-container"
      :class="{ 'sl-container--dragging': isDragging }"
      :style="containerElasticStyle"
      @mousedown="onMouseDown"
      @touchstart.prevent="onTouchStart"
    >
      <div class="sl-fill" :style="fillStyle" />
      <div
        class="sl-thumb"
        :style="thumbPosStyle"
        :class="{ 'sl-thumb--active': isDragging, 'sl-thumb--pulse': isPulse }"
      />
      <span class="sl-label">{{ label }}</span>
      <span class="sl-value">{{ fmt(modelValue) }}</span>
    </div>
  </div>
</template>

<style scoped lang="scss">
.sl-container {
  position: relative;
  height: 48px;
  border-radius: 12px;
  background: var(--color-surface);
  box-shadow: 0 0 0 1px var(--color-border);
  overflow: hidden;
  cursor: pointer;
  display: flex;
  align-items: center;
  padding: 0 18px;
}

.sl-fill {
  position: absolute;
  inset: 0 auto 0 0;
  background: oklch(from var(--color-text) l c h / 0.052);
}

.sl-thumb {
  position: absolute;
  top: 24%;
  bottom: 24%;
  width: 2px;
  border-radius: 2px;
  background: oklch(from var(--color-text-secondary) l c h / 0.5);
  transform: translateX(-50%);
}

.sl-label {
  flex: 1;
  font-size: 15px;
  font-weight: 450;
  color: var(--color-text-secondary);
}

.sl-value {
  font-size: 17px;
  font-weight: 600;
  color: var(--color-text);
  letter-spacing: -0.34px;
}
</style>

Usage

<!-- Fluid (default) -->
<Slider v-model="value" :min="0" :max="100" mode="fluid" label="Volume" suffix="%" />

<!-- Stepped with dot markers every 10 units -->
<Slider v-model="value" :min="0" :max="100" :step="10" :stepper-every="10" mode="stepped" label="Steps" />

<!-- Elastic — stretches at the boundary -->
<Slider v-model="value" :min="0" :max="100" mode="elastic" label="Elastic" :max-expand="12" />