# iOS Slide to Unlock

> Slide-to-unlock control: drag past 85% to confirm, keyboard accessible, with a shimmering label.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/ios-slide-to-unlock
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/ios-slide-to-unlock.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/ios-slide-to-unlock-demo.tsx"
import { IOSSlideToUnlock } from "@/components/ios-slide-to-unlock"

export default function IOSSlideToUnlockDemo() {
  return (
    <div className="flex w-full max-w-md justify-center rounded-2xl bg-gradient-to-b from-neutral-800 to-black p-10">
      <IOSSlideToUnlock />
    </div>
  )
}
```

## Source

```tsx title="components/ios-slide-to-unlock.tsx"
"use client"

import * as React from "react"
import { Check, ChevronRight } from "lucide-react"

import { cn } from "@/lib/utils"

const keyframes = `@keyframes ios-slide-shimmer{from{background-position:200% 0}to{background-position:-200% 0}}`
const THUMB = 48
const PAD = 4

export interface IOSSlideToUnlockProps extends Omit<React.ComponentProps<"div">, "onChange"> {
  label?: string
  doneLabel?: string
  onUnlock?: () => void
}

export function IOSSlideToUnlock({
  label = "slide to unlock",
  doneLabel = "Unlocked",
  onUnlock,
  className,
  ...props
}: IOSSlideToUnlockProps) {
  const trackRef = React.useRef<HTMLDivElement>(null)
  const drag = React.useRef({ origin: 0, max: 0 })
  const [x, setX] = React.useState(0)
  const [max, setMax] = React.useState(1)
  const [dragging, setDragging] = React.useState(false)
  const [done, setDone] = React.useState(false)

  React.useEffect(() => {
    if (!done) return
    const id = setTimeout(() => {
      setDone(false)
      setX(0)
    }, 1800)
    return () => clearTimeout(id)
  }, [done])

  const complete = (travel: number) => {
    setX(travel)
    setDone(true)
    onUnlock?.()
  }

  const measure = () => (trackRef.current?.clientWidth ?? 0) - THUMB - PAD * 2

  return (
    <div
      ref={trackRef}
      className={cn("relative h-14 w-full max-w-xs overflow-hidden rounded-full bg-neutral-900/90 p-1 select-none dark:bg-white/15", className)}
      {...props}
    >
      <style href="ios-slide-to-unlock" precedence="default">
        {keyframes}
      </style>
      <span
        aria-live="polite"
        className="pointer-events-none absolute inset-0 flex items-center justify-center pl-10 text-[17px] font-medium"
        style={{
          opacity: done ? 1 : Math.max(0, 1 - x / (max * 0.6)),
          color: done ? "#fff" : "transparent",
          backgroundImage: done ? undefined : "linear-gradient(90deg,rgba(255,255,255,0.35) 40%,#fff 50%,rgba(255,255,255,0.35) 60%)",
          backgroundSize: "200% 100%",
          WebkitBackgroundClip: "text",
          backgroundClip: "text",
          animation: done ? undefined : "ios-slide-shimmer 2.4s linear infinite",
        }}
      >
        {done ? doneLabel : label}
      </span>
      <button
        type="button"
        aria-label={`${label} (press Enter to unlock)`}
        onPointerDown={(event) => {
          if (done) return
          event.currentTarget.setPointerCapture(event.pointerId)
          const travel = measure()
          drag.current = { origin: event.clientX - x, max: travel }
          setMax(travel)
          setDragging(true)
        }}
        onPointerMove={(event) => {
          if (!dragging) return
          setX(Math.min(Math.max(event.clientX - drag.current.origin, 0), drag.current.max))
        }}
        onPointerUp={() => {
          if (!dragging) return
          setDragging(false)
          if (x >= drag.current.max * 0.85) complete(drag.current.max)
          else setX(0)
        }}
        onPointerCancel={() => {
          setDragging(false)
          setX(0)
        }}
        onKeyDown={(event) => {
          if (done || (event.key !== "Enter" && event.key !== " ")) return
          event.preventDefault()
          const travel = measure()
          setMax(travel)
          complete(travel)
        }}
        style={{ transform: `translateX(${x}px)`, touchAction: "none" }}
        className={cn(
          "relative flex size-12 cursor-grab items-center justify-center rounded-full bg-white text-neutral-900 shadow-md active:cursor-grabbing",
          "focus-visible:ring-[3px] focus-visible:ring-[#007aff]/60 focus-visible:outline-none",
          !dragging && "transition-transform duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]"
        )}
      >
        {done ? <Check className="size-5" strokeWidth={3} /> : <ChevronRight className="size-6" strokeWidth={2.5} />}
      </button>
    </div>
  )
}
```
