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
<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>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.
npx klean-ui add slidepnpm dlx klean-ui add slideyarn dlx klean-ui add slidebunx 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
<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
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
<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
| Input | Vue | React | Svelte | Purpose |
|---|---|---|---|---|
| Disabled | :disabled | disabled | disabled | Native disabled state; no confirmation can fire. |
| Pending | :pending | pending | pending | Caller-owned in-progress truth; disables duplicates and sets aria-busy. |
| Confirmation | @confirm | onConfirm | onconfirm | Called once after a valid slide or native button activation. |
| Styling | class | className | class | Ordinary Tailwind merged after neutral defaults. |
| Content | default slot | children | default snippet | Visible, 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:
<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
<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
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
<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>