Skip to content

Slide

Slide confirms an action while making accidental pointer activation difficult. Drag the thumb near the end and release, or focus the control and press Enter or Space.

Slide.vue

Installation

One command detects Vue, React, or Svelte and installs the framework-native source:

Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.

Terminal
npx klean-ui add slide

  • No initializer or configuration file
  • No framework, alias, or theme questions
  • No Klean runtime dependency

The installed file belongs to the application. There is no initializer, Klean runtime, configuration file, provider, alias prompt, or generated class helper.

Usage

The HTML and behavior stay the same in every framework. Only binding and event syntax change.

Vue

DeployAction.vue
<script setup>
import { ref } from 'vue'
import Slide from '@/components/ui/slide/Slide.vue'

const deploying = ref(false)

async function deploy() {
  deploying.value = true

  try {
    await fetch('/deployments', { method: 'POST' })
  } finally {
    deploying.value = false
  }
}
</script>

<template>
  <Slide
    :pending="deploying"
    :disabled="!ready"
    class="w-72"
    aria-describedby="deploy-help"
    @confirm="deploy"
  >
    {{ deploying ? 'Sliding to production…' : 'Slide to production' }}
  </Slide>
  <p id="deploy-help">Release near the end to start deployment.</p>
</template>

React

DeployAction.jsx
import { useState } from 'react'
import Slide from '@/components/ui/slide/Slide.jsx'

export default function DeployAction({ ready }) {
  const [deploying, setDeploying] = useState(false)

  async function deploy() {
    setDeploying(true)

    try {
      await fetch('/deployments', { method: 'POST' })
    } finally {
      setDeploying(false)
    }
  }

  return (
    <>
      <Slide
        pending={deploying}
        disabled={!ready}
        className="w-72"
        aria-describedby="deploy-help"
        onConfirm={deploy}
      >
        {deploying ? 'Sliding to production…' : 'Slide to production'}
      </Slide>
      <p id="deploy-help">Release near the end to start deployment.</p>
    </>
  )
}

Svelte

DeployAction.svelte
<script>
  import Slide from '@/components/ui/slide/Slide.svelte'

  let { ready = true } = $props()
  let deploying = $state(false)

  async function deploy() {
    deploying = true

    try {
      await fetch('/deployments', { method: 'POST' })
    } finally {
      deploying = false
    }
  }
</script>

<Slide
  pending={deploying}
  disabled={!ready}
  class="w-72"
  aria-describedby="deploy-help"
  onconfirm={deploy}
>
  {deploying ? 'Sliding to production…' : 'Slide to production'}
</Slide>
<p id="deploy-help">Release near the end to start deployment.</p>

Why this is a button

Slide confirms an action; it does not choose a value. It therefore renders a real <button type="button">, never an <input type="range"> and never role="slider".

That semantic choice gives Enter, Space, focus, disabled behavior, and assistive-technology activation their native meaning. The horizontal slide is a pointer enhancement for mouse, touch, and pen. It is not the only way to complete the action.

A future range-value control would be named Slider and would use native slider semantics. Combining the two contracts would make both harder to understand.

API

InputVueReactSveltePurpose
Disabled:disableddisableddisabledNative disabled state; no confirmation can fire.
Pending:pendingpendingpendingCaller-owned in-progress truth; disables duplicates and sets aria-busy.
Confirmation@confirmonConfirmonconfirmCalled once after a valid slide or native button activation.
StylingclassclassNameclassOrdinary Tailwind merged after neutral defaults.
Contentdefault slotchildrendefault snippetVisible, product-owned action language.

Native aria-describedby, name, value, form, data attributes, and other ordinary button attributes pass through. The confirmation threshold is the one conventional 85% behavior, not an application setting.

Keep pending truthful. Set it before starting asynchronous work, then return it to false on success or failure. That reset is declarative; there is no imperative reset() method.

Styling progress

Slide is neutral monochrome by default. Products may change color as the thumb moves using ordinary Tailwind selectors:

deployment-action.vue
<Slide
  class="w-72 border-gray-200 bg-gray-100 text-gray-500 shadow-none **:data-[slot=slide-fill]:bg-amber-500/10 **:data-[slot=slide-thumb]:bg-gray-950 [&[data-progress=middle]_[data-slot=slide-thumb]]:bg-amber-500 [&[data-progress=ready]_[data-slot=slide-thumb]]:bg-emerald-500 [&[data-progress=complete]_[data-slot=slide-thumb]]:bg-emerald-500 [&[data-progress=ready]_[data-slot=slide-fill]]:bg-emerald-500/10 [&[data-progress=complete]_[data-slot=slide-fill]]:bg-emerald-500/10"
  @confirm="deploy"
>
  Slide to production
</Slide>

The root exposes data-progress="start|middle|ready|complete". The fill and thumb expose data-slot="slide-fill" and data-slot="slide-thumb". These are styling hooks, not extra component objects or part-class props.

There are no variants, tones, color props, fillClass, thumbClass, or theme provider. When a treatment repeats within an application, extract an application component around Slide and keep the product name there.

Durable behavior

  • Releasing before the threshold returns to idle without confirming.
  • Escape, an interrupted pointer gesture, or lost capture cancels cleanly.
  • Focus stays on the button after cancellation, confirmation, failure, and reset.
  • Pending and disabled states cannot emit duplicate confirmation.
  • Track and thumb geometry are measured from the rendered control, including responsive resizing.
  • Logical direction keeps the interaction correct in RTL.
  • Reduced-motion preferences remove movement transitions without hiding progress.
  • Progress is ephemeral and is never written to storage, the URL, cookies, or server state.

Color is never the only progress signal: the thumb position moves, the visible label changes to “Release to confirm” near completion, and a polite status region announces meaningful state changes.

Complete framework source

Copy, inspect, and change the complete source for your framework.

Vue source

Slide.vue
<script setup>
import {
  computed,
  nextTick,
  onBeforeUnmount,
  onMounted,
  ref,
  useAttrs,
  watch
} from 'vue'
import { twMerge } from 'tailwind-merge'

defineOptions({ inheritAttrs: false })

const props = defineProps({
  /** Prevents pointer and native button activation. */
  disabled: { type: Boolean, default: false },
  /** Truthful caller-owned action state. Also prevents duplicate confirmation. */
  pending: { type: Boolean, default: false }
})

const emit = defineEmits(['confirm'])
const attrs = useAttrs()
const button = ref()
const thumb = ref()
const progress = ref(0)
const travel = ref(0)
const direction = ref(1)
const pointerId = ref()
const startX = ref(0)
const startProgress = ref(0)
const confirmed = ref(false)
const status = ref('')
let suppressClick = false
let resizeObserver

const CONFIRM_THRESHOLD = 0.85

const baseClasses = [
  'group/slide relative inline-grid min-h-11 w-56 max-w-full touch-none cursor-grab select-none overflow-hidden rounded-full border border-gray-300 bg-gray-100 p-1 text-sm font-medium text-gray-700 shadow-sm outline-none',
  'focus-visible:ring-2 focus-visible:ring-gray-950 focus-visible:ring-offset-2',
  'disabled:cursor-not-allowed disabled:opacity-50',
  'dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus-visible:ring-white'
]

const fillClasses = [
  'pointer-events-none absolute inset-y-0 inset-s-0 bg-gray-200',
  'transition-[width,background-color] duration-200 ease-out group-data-[state=dragging]/slide:transition-none motion-reduce:transition-none',
  'dark:bg-gray-800'
]

const thumbClasses = [
  'pointer-events-none absolute top-1 inset-s-1 z-20 flex size-9 items-center justify-center rounded-full bg-gray-950 text-white shadow-sm',
  'transition-[transform,background-color,color] duration-200 ease-out group-data-[state=dragging]/slide:transition-none motion-reduce:transition-none',
  'dark:bg-white dark:text-gray-950'
]

const isDragging = computed(() => pointerId.value !== undefined)
const isReady = computed(() => progress.value >= CONFIRM_THRESHOLD)
const state = computed(() =>
  props.pending
    ? 'pending'
    : isDragging.value
      ? 'dragging'
      : confirmed.value
        ? 'confirmed'
        : 'idle'
)
const progressState = computed(() => {
  if (props.pending || confirmed.value) return 'complete'
  if (progress.value >= CONFIRM_THRESHOLD) return 'ready'
  if (progress.value >= 0.33) return 'middle'
  return 'start'
})
const buttonClasses = computed(() => twMerge(baseClasses, attrs.class))
const buttonAttrs = computed(() => {
  const {
    class: _class,
    type: _type,
    disabled: _disabled,
    'aria-busy': _ariaBusy,
    'data-slot': _dataSlot,
    'data-state': _dataState,
    'data-progress': _dataProgress,
    onClick: _onClick,
    onKeydown: _onKeydown,
    onKeyDown: _onKeyDown,
    onPointerdown: _onPointerdown,
    onPointerDown: _onPointerDown,
    onPointermove: _onPointermove,
    onPointerMove: _onPointerMove,
    onPointerup: _onPointerup,
    onPointerUp: _onPointerUp,
    onPointercancel: _onPointercancel,
    onPointerCancel: _onPointerCancel,
    onLostpointercapture: _onLostPointercapture,
    onLostPointerCapture: _onLostPointerCapture,
    ...rest
  } = attrs

  return rest
})
const thumbStyle = computed(() => ({
  transform: `translateX(${direction.value * progress.value * travel.value}px)`
}))
const fillStyle = computed(() => ({ width: `${progress.value * 100}%` }))

function callListener(listener, event) {
  for (const callback of Array.isArray(listener) ? listener : [listener]) {
    callback?.(event)
  }
}

function measure() {
  if (!button.value || !thumb.value || typeof getComputedStyle === 'undefined')
    return

  const buttonStyle = getComputedStyle(button.value)
  const thumbStyle = getComputedStyle(thumb.value)
  const inlineStart = Number.parseFloat(thumbStyle.insetInlineStart) || 0

  direction.value =
    button.value.dir === 'rtl' || buttonStyle.direction === 'rtl' ? -1 : 1
  travel.value = Math.max(
    0,
    button.value.clientWidth - thumb.value.offsetWidth - inlineStart * 2
  )
}

function setProgress(nextProgress) {
  const wasReady = isReady.value
  progress.value = Math.max(0, Math.min(1, nextProgress))

  if (!wasReady && isReady.value) status.value = 'Release to confirm.'
  else if (wasReady && !isReady.value) status.value = 'Keep sliding.'
}

function clearPointer(releaseCapture = true) {
  const activePointer = pointerId.value
  pointerId.value = undefined

  if (
    releaseCapture &&
    activePointer !== undefined &&
    button.value?.hasPointerCapture?.(activePointer)
  ) {
    button.value.releasePointerCapture(activePointer)
  }
}

function reset(nextStatus = '') {
  clearPointer()
  confirmed.value = false
  progress.value = 0
  status.value = nextStatus
}

function cancel() {
  if (!isDragging.value) return
  reset('Slide cancelled.')
}

function confirm() {
  if (props.disabled || props.pending || confirmed.value) return

  clearPointer()
  confirmed.value = true
  progress.value = 1
  status.value = 'Confirmed.'
  emit('confirm')

  nextTick(() => {
    if (!props.pending) reset()
  })
}

function handlePointerdown(event) {
  callListener(attrs.onPointerdown ?? attrs.onPointerDown, event)
  if (
    event.defaultPrevented ||
    props.disabled ||
    props.pending ||
    !event.isPrimary ||
    (event.pointerType === 'mouse' && event.button !== 0)
  ) {
    return
  }

  event.preventDefault()
  button.value?.focus({ preventScroll: true })
  measure()
  confirmed.value = false
  pointerId.value = event.pointerId
  startX.value = event.clientX
  startProgress.value = progress.value
  status.value = 'Sliding. Move to the end, then release to confirm.'
  button.value?.setPointerCapture?.(event.pointerId)
}

function handlePointermove(event) {
  callListener(attrs.onPointermove ?? attrs.onPointerMove, event)
  if (event.pointerId !== pointerId.value || event.defaultPrevented) return

  event.preventDefault()
  measure()
  const delta = direction.value * (event.clientX - startX.value)
  setProgress(startProgress.value + (travel.value ? delta / travel.value : 0))
}

function handlePointerup(event) {
  callListener(attrs.onPointerup ?? attrs.onPointerUp, event)
  if (event.pointerId !== pointerId.value) return

  suppressClick = true
  queueMicrotask(() => {
    suppressClick = false
  })
  if (isReady.value && !event.defaultPrevented) confirm()
  else reset('Slide cancelled.')
}

function handlePointercancel(event) {
  callListener(attrs.onPointercancel ?? attrs.onPointerCancel, event)
  if (event.pointerId === pointerId.value) reset('Slide cancelled.')
}

function handleLostPointercapture(event) {
  callListener(attrs.onLostpointercapture ?? attrs.onLostPointerCapture, event)
  if (event.pointerId === pointerId.value) reset('Slide cancelled.')
}

function handleClick(event) {
  if (suppressClick) {
    suppressClick = false
    event.preventDefault()
    return
  }

  if (event.detail !== 0) {
    event.preventDefault()
    return
  }

  callListener(attrs.onClick, event)
  if (!event.defaultPrevented) confirm()
}

function handleKeydown(event) {
  callListener(attrs.onKeydown ?? attrs.onKeyDown, event)
  if (event.defaultPrevented || event.key !== 'Escape') return

  cancel()
}

watch(
  () => props.pending,
  (pending, wasPending) => {
    if (pending) {
      clearPointer()
      confirmed.value = true
      progress.value = 1
      status.value = 'Action in progress.'
    } else if (wasPending) {
      reset()
    }
  },
  { immediate: true }
)

watch(
  () => props.disabled,
  (disabled) => {
    if (disabled) cancel()
  }
)

onMounted(() => {
  measure()
  if (typeof ResizeObserver === 'undefined') return

  resizeObserver = new ResizeObserver(measure)
  if (button.value) resizeObserver.observe(button.value)
  if (thumb.value) resizeObserver.observe(thumb.value)
})

onBeforeUnmount(() => {
  clearPointer()
  resizeObserver?.disconnect()
})
</script>

<template>
  <button
    ref="button"
    v-bind="buttonAttrs"
    type="button"
    :disabled="disabled || pending"
    :aria-busy="pending ? 'true' : attrs['aria-busy']"
    data-slot="slide"
    :data-state="state"
    :data-progress="progressState"
    :class="buttonClasses"
    @click="handleClick"
    @keydown="handleKeydown"
    @pointerdown="handlePointerdown"
    @pointermove="handlePointermove"
    @pointerup="handlePointerup"
    @pointercancel="handlePointercancel"
    @lostpointercapture="handleLostPointercapture"
  >
    <span
      aria-hidden="true"
      data-slot="slide-fill"
      :class="fillClasses"
      :style="fillStyle"
    />
    <span
      data-slot="slide-label"
      class="pointer-events-none relative z-10 flex min-w-0 items-center justify-center px-11 text-center"
    >
      <span v-if="isReady && !pending">Release to confirm</span>
      <slot v-else />
    </span>
    <span
      ref="thumb"
      aria-hidden="true"
      data-slot="slide-thumb"
      :class="thumbClasses"
      :style="thumbStyle"
    >
      <svg
        class="size-4 rtl:rotate-180"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        stroke-width="2"
      >
        <path d="m9 5 7 7-7 7" stroke-linecap="round" stroke-linejoin="round" />
      </svg>
    </span>
    <span data-slot="slide-status" class="sr-only" aria-live="polite">
      {{ status }}
    </span>
  </button>
</template>

React source

Slide.jsx
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react'
import { twMerge } from 'tailwind-merge'

const CONFIRM_THRESHOLD = 0.85

const BASE_CLASSES = [
  'group/slide relative inline-grid min-h-11 w-56 max-w-full touch-none cursor-grab select-none overflow-hidden rounded-full border border-gray-300 bg-gray-100 p-1 text-sm font-medium text-gray-700 shadow-sm outline-none',
  'focus-visible:ring-2 focus-visible:ring-gray-950 focus-visible:ring-offset-2',
  'disabled:cursor-not-allowed disabled:opacity-50',
  'dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus-visible:ring-white'
]

const FILL_CLASSES = [
  'pointer-events-none absolute inset-y-0 inset-s-0 bg-gray-200',
  'transition-[width,background-color] duration-200 ease-out group-data-[state=dragging]/slide:transition-none motion-reduce:transition-none',
  'dark:bg-gray-800'
]

const THUMB_CLASSES = [
  'pointer-events-none absolute top-1 inset-s-1 z-20 flex size-9 items-center justify-center rounded-full bg-gray-950 text-white shadow-sm',
  'transition-[transform,background-color,color] duration-200 ease-out group-data-[state=dragging]/slide:transition-none motion-reduce:transition-none',
  'dark:bg-white dark:text-gray-950'
]

const Slide = forwardRef(function Slide(
  {
    disabled = false,
    pending = false,
    className,
    children,
    onConfirm,
    onClick,
    onKeyDown,
    onPointerDown,
    onPointerMove,
    onPointerUp,
    onPointerCancel,
    onLostPointerCapture,
    'aria-busy': ariaBusy,
    ...buttonProps
  },
  forwardedRef
) {
  const buttonRef = useRef(null)
  const thumbRef = useRef(null)
  const activePointer = useRef()
  const startX = useRef(0)
  const startProgress = useRef(0)
  const progressRef = useRef(0)
  const travelRef = useRef(0)
  const directionRef = useRef(1)
  const confirmedRef = useRef(false)
  const suppressClick = useRef(false)
  const pendingRef = useRef(pending)
  const previousPending = useRef(pending)
  const [progress, setRenderedProgress] = useState(pending ? 1 : 0)
  const [travel, setTravel] = useState(0)
  const [direction, setDirection] = useState(1)
  const [confirmed, setConfirmed] = useState(pending)
  const [status, setStatus] = useState(pending ? 'Action in progress.' : '')

  pendingRef.current = pending

  const setRef = useCallback(
    (element) => {
      buttonRef.current = element
      if (typeof forwardedRef === 'function') forwardedRef(element)
      else if (forwardedRef) forwardedRef.current = element
    },
    [forwardedRef]
  )

  const setProgress = useCallback((nextProgress) => {
    const wasReady = progressRef.current >= CONFIRM_THRESHOLD
    const boundedProgress = Math.max(0, Math.min(1, nextProgress))
    progressRef.current = boundedProgress
    setRenderedProgress(boundedProgress)

    if (!wasReady && boundedProgress >= CONFIRM_THRESHOLD) {
      setStatus('Release to confirm.')
    } else if (wasReady && boundedProgress < CONFIRM_THRESHOLD) {
      setStatus('Keep sliding.')
    }
  }, [])

  const measure = useCallback(() => {
    const element = buttonRef.current
    const thumb = thumbRef.current
    if (!element || !thumb || typeof getComputedStyle === 'undefined') return

    const elementStyle = getComputedStyle(element)
    const thumbStyle = getComputedStyle(thumb)
    const inlineStart = Number.parseFloat(thumbStyle.insetInlineStart) || 0
    const nextDirection =
      element.dir === 'rtl' || elementStyle.direction === 'rtl' ? -1 : 1
    const nextTravel = Math.max(
      0,
      element.clientWidth - thumb.offsetWidth - inlineStart * 2
    )

    directionRef.current = nextDirection
    travelRef.current = nextTravel
    setDirection(nextDirection)
    setTravel(nextTravel)
  }, [])

  const clearPointer = useCallback((releaseCapture = true) => {
    const pointerId = activePointer.current
    activePointer.current = undefined

    if (
      releaseCapture &&
      pointerId !== undefined &&
      buttonRef.current?.hasPointerCapture?.(pointerId)
    ) {
      buttonRef.current.releasePointerCapture(pointerId)
    }
  }, [])

  const reset = useCallback(
    (nextStatus = '') => {
      clearPointer()
      confirmedRef.current = false
      setConfirmed(false)
      setProgress(0)
      setStatus(nextStatus)
    },
    [clearPointer, setProgress]
  )

  const cancel = useCallback(() => {
    if (activePointer.current === undefined) return
    reset('Slide cancelled.')
  }, [reset])

  const confirm = useCallback(() => {
    if (disabled || pending || confirmedRef.current) return

    clearPointer()
    confirmedRef.current = true
    setConfirmed(true)
    setProgress(1)
    setStatus('Confirmed.')
    onConfirm?.()

    queueMicrotask(() => {
      if (!pendingRef.current) reset()
    })
  }, [clearPointer, disabled, onConfirm, pending, reset, setProgress])

  useEffect(() => {
    measure()
    if (typeof ResizeObserver === 'undefined') return

    const observer = new ResizeObserver(measure)
    if (buttonRef.current) observer.observe(buttonRef.current)
    if (thumbRef.current) observer.observe(thumbRef.current)

    return () => observer.disconnect()
  }, [measure])

  useEffect(() => {
    const wasPending = previousPending.current
    previousPending.current = pending

    if (pending) {
      clearPointer()
      confirmedRef.current = true
      setConfirmed(true)
      setProgress(1)
      setStatus('Action in progress.')
    } else if (wasPending) {
      reset()
    }
  }, [clearPointer, pending, reset, setProgress])

  useEffect(() => {
    if (disabled) cancel()
  }, [cancel, disabled])

  useEffect(() => () => clearPointer(), [clearPointer])

  function handlePointerDown(event) {
    onPointerDown?.(event)
    const nativeEvent = event.nativeEvent
    if (
      event.defaultPrevented ||
      disabled ||
      pending ||
      !nativeEvent.isPrimary ||
      (nativeEvent.pointerType === 'mouse' && nativeEvent.button !== 0)
    ) {
      return
    }

    event.preventDefault()
    buttonRef.current?.focus({ preventScroll: true })
    measure()
    confirmedRef.current = false
    setConfirmed(false)
    activePointer.current = nativeEvent.pointerId
    startX.current = nativeEvent.clientX
    startProgress.current = progressRef.current
    setStatus('Sliding. Move to the end, then release to confirm.')
    buttonRef.current?.setPointerCapture?.(nativeEvent.pointerId)
  }

  function handlePointerMove(event) {
    onPointerMove?.(event)
    const nativeEvent = event.nativeEvent
    if (
      nativeEvent.pointerId !== activePointer.current ||
      event.defaultPrevented
    ) {
      return
    }

    event.preventDefault()
    measure()
    const delta = directionRef.current * (nativeEvent.clientX - startX.current)
    setProgress(
      startProgress.current +
        (travelRef.current ? delta / travelRef.current : 0)
    )
  }

  function handlePointerUp(event) {
    onPointerUp?.(event)
    if (event.nativeEvent.pointerId !== activePointer.current) return

    suppressClick.current = true
    queueMicrotask(() => {
      suppressClick.current = false
    })
    if (progressRef.current >= CONFIRM_THRESHOLD && !event.defaultPrevented) {
      confirm()
    } else {
      reset('Slide cancelled.')
    }
  }

  function handlePointerCancel(event) {
    onPointerCancel?.(event)
    if (event.nativeEvent.pointerId === activePointer.current) {
      reset('Slide cancelled.')
    }
  }

  function handleLostPointerCapture(event) {
    onLostPointerCapture?.(event)
    if (event.nativeEvent.pointerId === activePointer.current) {
      reset('Slide cancelled.')
    }
  }

  function handleClick(event) {
    if (suppressClick.current) {
      suppressClick.current = false
      event.preventDefault()
      return
    }

    if (event.detail !== 0) {
      event.preventDefault()
      return
    }

    onClick?.(event)
    if (!event.defaultPrevented) confirm()
  }

  function handleKeyDown(event) {
    onKeyDown?.(event)
    if (event.defaultPrevented || event.key !== 'Escape') return

    cancel()
  }

  const dragging = activePointer.current !== undefined
  const ready = progress >= CONFIRM_THRESHOLD
  const state = pending
    ? 'pending'
    : dragging
      ? 'dragging'
      : confirmed
        ? 'confirmed'
        : 'idle'
  const progressState =
    pending || confirmed
      ? 'complete'
      : ready
        ? 'ready'
        : progress >= 0.33
          ? 'middle'
          : 'start'

  return (
    <button
      {...buttonProps}
      ref={setRef}
      type="button"
      disabled={disabled || pending}
      aria-busy={pending ? true : ariaBusy}
      data-slot="slide"
      data-state={state}
      data-progress={progressState}
      className={twMerge(BASE_CLASSES, className)}
      onClick={handleClick}
      onKeyDown={handleKeyDown}
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={handlePointerUp}
      onPointerCancel={handlePointerCancel}
      onLostPointerCapture={handleLostPointerCapture}
    >
      <span
        aria-hidden="true"
        data-slot="slide-fill"
        className={FILL_CLASSES.join(' ')}
        style={{ width: `${progress * 100}%` }}
      />
      <span
        data-slot="slide-label"
        className="pointer-events-none relative z-10 flex min-w-0 items-center justify-center px-11 text-center"
      >
        {ready && !pending ? 'Release to confirm' : children}
      </span>
      <span
        ref={thumbRef}
        aria-hidden="true"
        data-slot="slide-thumb"
        className={THUMB_CLASSES.join(' ')}
        style={{ transform: `translateX(${direction * progress * travel}px)` }}
      >
        <svg
          className="size-4 rtl:rotate-180"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          strokeWidth="2"
        >
          <path d="m9 5 7 7-7 7" strokeLinecap="round" strokeLinejoin="round" />
        </svg>
      </span>
      <span data-slot="slide-status" className="sr-only" aria-live="polite">
        {status}
      </span>
    </button>
  )
})

export default Slide

Svelte source

Slide.svelte
<script>
  import { onMount, untrack } from "svelte";
  import { twMerge } from "tailwind-merge";

  const CONFIRM_THRESHOLD = 0.85;

  const BASE_CLASSES = [
    "group/slide relative inline-grid min-h-11 w-56 max-w-full touch-none cursor-grab select-none overflow-hidden rounded-full border border-gray-300 bg-gray-100 p-1 text-sm font-medium text-gray-700 shadow-sm outline-none",
    "focus-visible:ring-2 focus-visible:ring-gray-950 focus-visible:ring-offset-2",
    "disabled:cursor-not-allowed disabled:opacity-50",
    "dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus-visible:ring-white",
  ];

  const FILL_CLASSES = [
    "pointer-events-none absolute inset-y-0 inset-s-0 bg-gray-200",
    "transition-[width,background-color] duration-200 ease-out group-data-[state=dragging]/slide:transition-none motion-reduce:transition-none",
    "dark:bg-gray-800",
  ];

  const THUMB_CLASSES = [
    "pointer-events-none absolute top-1 inset-s-1 z-20 flex size-9 items-center justify-center rounded-full bg-gray-950 text-white shadow-sm",
    "transition-[transform,background-color,color] duration-200 ease-out group-data-[state=dragging]/slide:transition-none motion-reduce:transition-none",
    "dark:bg-white dark:text-gray-950",
  ];

  let {
    disabled = false,
    pending = false,
    class: className = "",
    onconfirm,
    onclick,
    onkeydown,
    onpointerdown,
    onpointermove,
    onpointerup,
    onpointercancel,
    onlostpointercapture,
    "aria-busy": ariaBusy,
    children,
    ...buttonProps
  } = $props();

  const initialPending = untrack(() => pending);

  let buttonElement;
  let thumbElement;
  let progress = $state(initialPending ? 1 : 0);
  let travel = $state(0);
  let direction = $state(1);
  let activePointer = $state();
  let startX = 0;
  let startProgress = 0;
  let confirmed = $state(initialPending);
  let status = $state(initialPending ? "Action in progress." : "");
  let suppressClick = false;
  let previousPending = initialPending;

  let dragging = $derived(activePointer !== undefined);
  let ready = $derived(progress >= CONFIRM_THRESHOLD);
  let state = $derived(
    pending
      ? "pending"
      : dragging
        ? "dragging"
        : confirmed
          ? "confirmed"
          : "idle",
  );
  let progressState = $derived(
    pending || confirmed
      ? "complete"
      : ready
        ? "ready"
        : progress >= 0.33
          ? "middle"
          : "start",
  );

  function measure() {
    if (
      !buttonElement ||
      !thumbElement ||
      typeof getComputedStyle === "undefined"
    )
      return;

    const buttonStyle = getComputedStyle(buttonElement);
    const thumbStyle = getComputedStyle(thumbElement);
    const inlineStart = Number.parseFloat(thumbStyle.insetInlineStart) || 0;

    direction =
      buttonElement.dir === "rtl" || buttonStyle.direction === "rtl" ? -1 : 1;
    travel = Math.max(
      0,
      buttonElement.clientWidth - thumbElement.offsetWidth - inlineStart * 2,
    );
  }

  function setProgress(nextProgress) {
    const wasReady = progress >= CONFIRM_THRESHOLD;
    progress = Math.max(0, Math.min(1, nextProgress));

    if (!wasReady && progress >= CONFIRM_THRESHOLD) {
      status = "Release to confirm.";
    } else if (wasReady && progress < CONFIRM_THRESHOLD) {
      status = "Keep sliding.";
    }
  }

  function clearPointer(releaseCapture = true) {
    const pointerId = activePointer;
    activePointer = undefined;

    if (
      releaseCapture &&
      pointerId !== undefined &&
      buttonElement?.hasPointerCapture?.(pointerId)
    ) {
      buttonElement.releasePointerCapture(pointerId);
    }
  }

  function reset(nextStatus = "") {
    clearPointer();
    confirmed = false;
    progress = 0;
    status = nextStatus;
  }

  function cancel() {
    if (activePointer === undefined) return;
    reset("Slide cancelled.");
  }

  function confirm() {
    if (disabled || pending || confirmed) return;

    clearPointer();
    confirmed = true;
    progress = 1;
    status = "Confirmed.";
    onconfirm?.();

    queueMicrotask(() => {
      if (!pending) reset();
    });
  }

  function handlePointerdown(event) {
    onpointerdown?.(event);
    if (
      event.defaultPrevented ||
      disabled ||
      pending ||
      !event.isPrimary ||
      (event.pointerType === "mouse" && event.button !== 0)
    ) {
      return;
    }

    event.preventDefault();
    buttonElement?.focus({ preventScroll: true });
    measure();
    confirmed = false;
    activePointer = event.pointerId;
    startX = event.clientX;
    startProgress = progress;
    status = "Sliding. Move to the end, then release to confirm.";
    buttonElement?.setPointerCapture?.(event.pointerId);
  }

  function handlePointermove(event) {
    onpointermove?.(event);
    if (event.pointerId !== activePointer || event.defaultPrevented) return;

    event.preventDefault();
    measure();
    const delta = direction * (event.clientX - startX);
    setProgress(startProgress + (travel ? delta / travel : 0));
  }

  function handlePointerup(event) {
    onpointerup?.(event);
    if (event.pointerId !== activePointer) return;

    suppressClick = true;
    queueMicrotask(() => {
      suppressClick = false;
    });
    if (ready && !event.defaultPrevented) confirm();
    else reset("Slide cancelled.");
  }

  function handlePointercancel(event) {
    onpointercancel?.(event);
    if (event.pointerId === activePointer) reset("Slide cancelled.");
  }

  function handleLostPointercapture(event) {
    onlostpointercapture?.(event);
    if (event.pointerId === activePointer) reset("Slide cancelled.");
  }

  function handleClick(event) {
    if (suppressClick) {
      suppressClick = false;
      event.preventDefault();
      return;
    }

    if (event.detail !== 0) {
      event.preventDefault();
      return;
    }

    onclick?.(event);
    if (!event.defaultPrevented) confirm();
  }

  function handleKeydown(event) {
    onkeydown?.(event);
    if (event.defaultPrevented || event.key !== "Escape") return;

    cancel();
  }

  $effect(() => {
    const busy = pending;
    const wasPending = previousPending;
    previousPending = busy;

    if (busy) {
      clearPointer();
      confirmed = true;
      progress = 1;
      status = "Action in progress.";
    } else if (wasPending) {
      reset();
    }
  });

  $effect(() => {
    if (disabled) cancel();
  });

  onMount(() => {
    measure();
    if (typeof ResizeObserver === "undefined") return;

    const observer = new ResizeObserver(measure);
    if (buttonElement) observer.observe(buttonElement);
    if (thumbElement) observer.observe(thumbElement);

    return () => {
      clearPointer();
      observer.disconnect();
    };
  });
</script>

<button
  {...buttonProps}
  bind:this={buttonElement}
  type="button"
  disabled={disabled || pending}
  aria-busy={pending ? "true" : ariaBusy}
  data-slot="slide"
  data-state={state}
  data-progress={progressState}
  class={twMerge(BASE_CLASSES, className)}
  onclick={handleClick}
  onkeydown={handleKeydown}
  onpointerdown={handlePointerdown}
  onpointermove={handlePointermove}
  onpointerup={handlePointerup}
  onpointercancel={handlePointercancel}
  onlostpointercapture={handleLostPointercapture}
>
  <span
    aria-hidden="true"
    data-slot="slide-fill"
    class={FILL_CLASSES.join(" ")}
    style:width={`${progress * 100}%`}
  ></span>
  <span
    data-slot="slide-label"
    class="pointer-events-none relative z-10 flex min-w-0 items-center justify-center px-11 text-center"
  >
    {#if ready && !pending}
      Release to confirm
    {:else}
      {@render children?.()}
    {/if}
  </span>
  <span
    bind:this={thumbElement}
    aria-hidden="true"
    data-slot="slide-thumb"
    class={THUMB_CLASSES.join(" ")}
    style:transform={`translateX(${direction * progress * travel}px)`}
  >
    <svg
      class="size-4 rtl:rotate-180"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      stroke-width="2"
    >
      <path d="m9 5 7 7-7 7" stroke-linecap="round" stroke-linejoin="round"
      ></path>
    </svg>
  </span>
  <span data-slot="slide-status" class="sr-only" aria-live="polite">
    {status}
  </span>
</button>

  • Button — the ordinary choice for actions that do not need extra friction.
  • Spinner — a decorative pending mark after confirmation starts real work.
  • Toast — announce the result after confirmation.
  • Dialog — gather context or explicit choices before a consequential action.

All open source projects are released under the MIT License.