<div class="lp-lock">
<div class="lp-dial">
<svg
class="lp-ring"
width="160"
height="160"
viewBox="0 0 160 160"
aria-hidden="true"
>
<circle class="lp-track" cx="80" cy="80" r="72" />
<circle class="lp-progress" cx="80" cy="80" r="72" />
</svg>
<button class="lp-button" aria-label="Hold to unlock">
<svg
class="lp-icon"
width="40"
height="40"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path class="lp-shackle" d="M8 11V7.5a4 4 0 0 1 8 0V11" />
<rect class="lp-body" x="5" y="11" width="14" height="10" rx="2.5" />
<circle class="lp-keyhole" cx="12" cy="16" r="1.25" />
</svg>
</button>
</div>
<p class="lp-label" aria-live="polite">Hold to unlock</p>
</div>
<script type="module">
import { animate, mapValue, motionValue, press, styleEffect } from "motion"
const button = document.querySelector(".lp-button")
const ring = document.querySelector(".lp-ring")
const progressCircle = document.querySelector(".lp-progress")
const shackle = document.querySelector(".lp-shackle")
const label = document.querySelector(".lp-label")
const ui = { type: "spring", stiffness: 305, damping: 33 }
const lively = { type: "spring", stiffness: 622, damping: 17 }
const circumference = 2 * Math.PI * 72
progressCircle.style.strokeDasharray = circumference
/**
* onHold reports 0 to 1 over the hold time. Everything that
* builds up during the hold reads from this one motion value.
*/
const progress = motionValue(0)
styleEffect(progressCircle, {
strokeDashoffset: mapValue(progress, [0, 1], [circumference, 0]),
opacity: mapValue(progress, [0, 0.05], [0, 1]),
})
styleEffect(shackle, {
y: mapValue(progress, [0, 1], [0, -1]),
})
let isUnlocked = false
let labelText = label.textContent
function setLabel(text) {
if (text === labelText) return
labelText = text
animate(
label,
{ opacity: 0, filter: "blur(4px)", y: -4 },
{ duration: 0.12 },
).then(() => {
if (text !== labelText) return
label.textContent = text
animate(
label,
{ opacity: [0, 1], filter: ["blur(4px)", "blur(0px)"], y: [4, 0] },
ui,
)
})
}
function unlock() {
isUnlocked = true
navigator.vibrate?.(12)
button.classList.add("lp-unlocked")
animate(shackle, { y: -4 }, lively)
animate(
ring,
{ scale: [1, 1.1], opacity: [1, 0] },
{ duration: 0.5, ease: [0.2, 0, 0, 1] },
)
setLabel("Unlocked")
setTimeout(lock, 1800)
}
function lock() {
button.classList.remove("lp-unlocked")
progress.set(0)
animate(shackle, { y: 0 }, lively)
animate(ring, { scale: 1, opacity: 1 }, { duration: 0 })
setLabel("Hold to unlock")
isUnlocked = false
}
press(
button,
() => {
if (isUnlocked) return
animate(button, { scale: 0.92 }, ui)
setLabel("Keep holding")
return {
onHold: (p) => progress.set(p),
onActive: unlock,
onComplete: (_event, { success }) => {
animate(button, { scale: 1 }, lively)
if (!success) {
animate(progress, 0, ui)
setLabel("Hold to unlock")
}
},
}
},
{ hold: 1 },
)
</script>
<style>
.lp-lock {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
user-select: none;
-webkit-user-select: none;
}
.lp-dial {
position: relative;
width: 160px;
height: 160px;
display: flex;
align-items: center;
justify-content: center;
}
.lp-ring {
position: absolute;
inset: 0;
pointer-events: none;
}
.lp-ring circle {
transform: rotate(-90deg);
transform-origin: center;
fill: none;
stroke-width: 5;
stroke-linecap: round;
}
.lp-track {
stroke: #eceef2;
}
.lp-progress {
stroke: #18222e;
transition: stroke 0.2s;
}
.lp-button {
width: 120px;
height: 120px;
border-radius: 50%;
border: none;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background: #ffffff;
color: #18222e;
box-shadow:
0 0 0 1px rgba(24, 34, 46, 0.06),
0 1px 2px rgba(24, 34, 46, 0.06),
0 8px 24px rgba(24, 34, 46, 0.08);
cursor: pointer;
touch-action: none;
-webkit-touch-callout: none;
transition:
background-color 0.3s,
color 0.3s;
}
.lp-button.lp-unlocked {
background: #2fbf71;
color: #ffffff;
}
.lp-dial:has(.lp-unlocked) .lp-progress {
stroke: #2fbf71;
}
.lp-button:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 6px;
}
.lp-icon {
overflow: visible;
}
.lp-shackle,
.lp-body {
fill: none;
stroke: currentColor;
stroke-width: 1.75;
stroke-linecap: round;
stroke-linejoin: round;
}
.lp-keyhole {
fill: currentColor;
}
.lp-label {
margin: 0;
font-size: 15px;
font-weight: 500;
letter-spacing: -0.01em;
color: #60687a;
}
</style>