Svelte 5 component · fractalsvelte-ui · Actions
SlideUnlock
A swipe-to-unlock gesture slider with draggable spring thumb, progress track fill, and success state. Designed for deliberate authorization and high-consequence triggers.
Install
SlideUnlock is a Svelte 5 slide unlock component in @fractaldesign/fractalsvelte-ui. It works in SvelteKit and in any Svelte 5 app.
pnpm add @fractaldesign/fractalsvelte-uiImport
import { SlideUnlock } from '@fractaldesign/fractalsvelte-ui';Usage
<SlideUnlock unlocked={false} label={'Slide to confirm'} successLabel={'Confirmed'}>Click me</SlideUnlock>Props
Also accepts every attribute of HTMLAttributes, spread onto
the root element.
| Name | Type | Default |
|---|---|---|
unlocked bindable | boolean | false |
label | string | 'Slide to confirm' |
successLabel | string | 'Confirmed' |
intent | 'primary' | Intent 'info' | 'success' | 'warning' | 'danger' | 'primary' |
disabled | boolean | false |
class | string | '' |
onunlock | () => void | — |
onreset | () => void | — |
Snippets
| Name | Type | Default |
|---|---|---|
children | Snippet | — |
Source
Show the SlideUnlock.svelte source
<script lang="ts">
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import type { Intent } from '../../types.js';
interface Props extends HTMLAttributes<HTMLDivElement> {
unlocked?: boolean;
label?: string;
successLabel?: string;
intent?: 'primary' | Intent;
disabled?: boolean;
class?: string;
onunlock?: () => void;
onreset?: () => void;
children?: Snippet;
}
let {
unlocked = $bindable(false),
label = 'Slide to confirm',
successLabel = 'Confirmed',
intent = 'primary',
disabled = false,
class: className = '',
onunlock,
onreset,
children,
...restProps
}: Props = $props();
let trackEl: HTMLDivElement | undefined = $state();
let thumbEl: HTMLDivElement | undefined = $state();
let offsetX = $state(0);
let isDragging = $state(false);
let maxDrag = $state(160);
function updateBounds() {
if (trackEl && thumbEl) {
maxDrag = Math.max(10, trackEl.clientWidth - thumbEl.clientWidth - 8);
}
}
function handlePointerDown(e: PointerEvent) {
if (disabled) return;
if (unlocked) {
// Click to reset
unlocked = false;
offsetX = 0;
onreset?.();
return;
}
updateBounds();
isDragging = true;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}
function handlePointerMove(e: PointerEvent) {
if (!isDragging || disabled || !trackEl) return;
const rect = trackEl.getBoundingClientRect();
const pos = e.clientX - rect.left - 24; // center thumb
offsetX = Math.min(maxDrag, Math.max(0, pos));
}
function handlePointerUp(e: PointerEvent) {
if (!isDragging) return;
isDragging = false;
try {
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
} catch {}
if (offsetX >= maxDrag * 0.75) {
offsetX = maxDrag;
unlocked = true;
onunlock?.();
} else {
offsetX = 0;
}
}
$effect(() => {
if (unlocked) {
updateBounds();
offsetX = maxDrag;
} else if (!isDragging) {
offsetX = 0;
}
});
</script>
<svelte:window onresize={updateBounds} />
<div
bind:this={trackEl}
class="f-slide-unlock {className}"
data-intent={intent}
data-unlocked={unlocked ? '' : undefined}
data-dragging={isDragging ? '' : undefined}
data-disabled={disabled ? '' : undefined}
role="slider"
tabindex={disabled ? -1 : 0}
aria-label={label}
aria-valuemin={0}
aria-valuemax={maxDrag}
aria-valuenow={offsetX}
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
onpointercancel={handlePointerUp}
{...restProps}
>
<div
class="f-slide-fill"
style="width: {offsetX + 48}px;"
></div>
<div
bind:this={thumbEl}
class="f-slide-thumb"
style="transform: translateX({offsetX}px);"
>
{#if unlocked}
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
{:else}
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 6 15 12 9 18" />
</svg>
{/if}
</div>
<div class="f-slide-label" data-unlocked={unlocked ? '' : undefined}>
{unlocked ? successLabel : label}
</div>
{#if children}
{@render children()}
{/if}
</div>
<style>
@layer fractalsvelte-ui.components {
.f-slide-unlock {
position: relative;
width: 100%;
max-width: 280px;
height: 48px;
background: var(--f-sunken);
border: 1px solid var(--f-border);
border-radius: 9999px;
overflow: hidden;
user-select: none;
touch-action: none;
cursor: grab;
box-sizing: border-box;
display: flex;
align-items: center;
padding: 4px;
transition: border-color var(--f-duration-normal) var(--f-ease);
}
.f-slide-unlock[data-dragging] {
cursor: grabbing;
}
.f-slide-unlock[data-unlocked] {
cursor: pointer;
border-color: var(--f-success, #10b981);
}
.f-slide-unlock[data-disabled] {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.f-slide-fill {
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-radius: 9999px;
background: var(--f-primary-muted, rgba(99, 102, 241, 0.2));
pointer-events: none;
transition: background var(--f-duration-fast) var(--f-ease);
}
.f-slide-unlock[data-unlocked] .f-slide-fill {
width: 100% !important;
background: var(--f-success-muted, rgba(16, 185, 129, 0.2));
}
.f-slide-thumb {
position: absolute;
left: 4px;
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--f-surface);
border: 1px solid var(--f-border);
box-shadow: var(--f-shadow-sm);
display: flex;
align-items: center;
justify-content: center;
color: var(--f-text);
z-index: 2;
pointer-events: none;
transition: transform 0.05s linear, background var(--f-duration-normal) var(--f-ease), color var(--f-duration-normal) var(--f-ease);
}
.f-slide-unlock:not([data-dragging]) .f-slide-thumb {
transition: transform var(--f-duration-normal) var(--f-ease), background var(--f-duration-normal) var(--f-ease), color var(--f-duration-normal) var(--f-ease);
}
.f-slide-unlock[data-unlocked] .f-slide-thumb {
background: var(--f-success, #10b981);
color: #ffffff;
border-color: transparent;
}
.f-slide-label {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding-left: 20px;
font-size: var(--f-text-xs);
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--f-text-muted);
pointer-events: none;
z-index: 1;
transition: opacity var(--f-duration-normal) var(--f-ease), color var(--f-duration-normal) var(--f-ease);
}
.f-slide-label[data-unlocked] {
padding-left: 0;
color: var(--f-success, #10b981);
}
/* Intents */
.f-slide-unlock[data-intent='success'] .f-slide-fill {
background: var(--f-success-muted, rgba(16, 185, 129, 0.2));
}
.f-slide-unlock[data-intent='danger'] .f-slide-fill {
background: var(--f-danger-muted, rgba(239, 68, 68, 0.2));
}
.f-slide-unlock[data-intent='danger'][data-unlocked] {
border-color: var(--f-danger, #ef4444);
}
.f-slide-unlock[data-intent='danger'][data-unlocked] .f-slide-thumb {
background: var(--f-danger, #ef4444);
}
}
</style>Related components
SplitButton A dual-action control pairing a primary button trigger with a secondary dropdown chevron button.Button An interactive trigger for user actions with support for multiple visual variants, sizes, shapes, and an…ButtonGroup A clustered group container that attaches adjacent buttons seamlessly with merged borders or separates them…CloseButton A standardized dismissal trigger for dialogs, drawers, toasts, and alerts.CopyButton A one-click clipboard copy utility that transitions into a confirmed checkmark state upon copying.HoldToConfirm A high-stakes press-and-hold confirmation button with animated progress fill.