# Elastic Slider

> A deep dive into building a slider with three interaction modes — fluid, stepped, and elastic — where the container physically stretches at the boundary and springs back.

Published 2026-03-27 by Jaber El Ferkh. Tags: vue, component, animations, ux, interactions.
Canonical page: https://jaberelferkh.com/topics/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.

::slider-demo
::

::divider
::

## Three modes, one component

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

::spacer
::

**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.

::spacer{size="small"}
::

## 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:

```typescript
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.

::divider
::

## Freezing the rect

```typescript
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.

::divider
::

## 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:

```typescript
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.

```typescript
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.

::divider
::

## 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:

```typescript
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.

::divider
::

## 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:

```typescript
isPulse.value = true;
setTimeout(() => { isPulse.value = false; }, 100);
```

```scss
.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.

::divider
::

## Full component

```vue
<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>
```

::spacer
::

## Usage

```vue
<!-- 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" />
```
