Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save rsp2k/cb5d4d746bb67d9b0a72846b07ef77cf to your computer and use it in GitHub Desktop.
Save rsp2k/cb5d4d746bb67d9b0a72846b07ef77cf to your computer and use it in GitHub Desktop.
hold to confirm signature w/ mirror playback ✍️
<button
aria-label="Sign Document"
class="toggle"
popovertarget="sign"
popovertargetaction="toggle"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
class="lucide lucide-pen-line"
viewBox="0 0 24 24"
>
<path
d="M12 20h9M16.38 3.62a1 1 0 0 1 3 3L7.37 18.64a2 2 0 0 1-.86.5l-2.87.84a.5.5 0 0 1-.62-.62l.84-2.87a2 2 0 0 1 .5-.86z"
/>
<path style="opacity: 0" d="M20 6 9 17l-5-5"></path>
</svg>
</button>
<div id="sign" popover="manual">
<span class="placeholder">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
class="lucide lucide-pen-line"
viewBox="0 0 24 24"
>
<path
id="morph"
d="M12 20h9M16.38 3.62a1 1 0 0 1 3 3L7.37 18.64a2 2 0 0 1-.86.5l-2.87.84a.5.5 0 0 1-.62-.62l.84-2.87a2 2 0 0 1 .5-.86z"
/>
<path
id="pen"
style="opacity: 0"
d="M12 20h9M16.38 3.62a1 1 0 0 1 3 3L7.37 18.64a2 2 0 0 1-.86.5l-2.87.84a.5.5 0 0 1-.62-.62l.84-2.87a2 2 0 0 1 .5-.86z"
/>
<path id="check" style="opacity: 0" d="M20 6 9 17l-5-5"></path>
</svg>
</span>
<div class="popover__content">
<header>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
class="lucide lucide-pen-line"
viewBox="0 0 24 24"
>
<path
id="sign"
d="M12 20h9M16.38 3.62a1 1 0 0 1 3 3L7.37 18.64a2 2 0 0 1-.86.5l-2.87.84a.5.5 0 0 1-.62-.62l.84-2.87a2 2 0 0 1 .5-.86z"
/>
<path id="check" style="opacity: 0" d="M20 6 9 17l-5-5"></path>
</svg>
<span>Draw signature</span>
</header>
<div data-clickable="true" class="canvas">
<canvas data-clickable="true"></canvas>
<div class="animated">
<svg xmlns="http://www.w3.org/2000/svg" fill="none">
<!-- if you opt to do this with basic CSS, use pathLength=1 -->
<path
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
d=""
/>
</svg>
</div>
</div>
<footer>
<div class="secondary">
<button class="erase" aria-label="Erase" title="Erase">
<span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-eraser"
>
<path
d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.4L13 21"
></path>
<path d="M22 21H7"></path>
<path d="m5 11 9 9"></path>
</svg>
</span>
</button>
<button aria-label="Use text input">
<span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-type"
>
<path d="M12 4v16"></path>
<path d="M4 7V5a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2"></path>
<path d="M9 20h6"></path>
</svg>
</span>
</button>
</div>
<button
class="hold"
aria-label="Hold to confirm"
title="Hold to confirm"
>
<span class="status">
<span class="track">
<span>Signed</span>
<span>Hold to confirm</span>
</span>
</span>
<span>Hold to confirm</span>
</button>
</footer>
</div>
</div>
<div class="targets">
<div class="target target--north"></div>
<div class="target target--west"></div>
<div class="target target--south-west"></div>
<div class="target target--south"></div>
<div class="target target--south-east"></div>
<div class="target target--east"></div>
</div>
<div class="info">
<span class="arrow arrow--instruction">
<span>open to sign</span>
<svg viewBox="0 0 97 52" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M74.568 0.553803C74.0753 0.881909 73.6295 1.4678 73.3713 2.12401C73.1367 2.70991 72.3858 4.67856 71.6584 6.50658C70.9544 8.35803 69.4526 11.8031 68.3498 14.1936C66.1441 19.0214 65.839 20.2167 66.543 21.576C67.4581 23.3337 69.4527 23.9196 71.3064 22.9821C72.4797 22.3728 74.8965 19.5839 76.9615 16.4435C78.8387 13.5843 78.8387 13.6077 78.1113 18.3418C77.3369 23.4275 76.4687 26.2866 74.5915 30.0364C73.254 32.7316 71.8461 34.6299 69.218 37.3485C65.9563 40.6999 62.2254 42.9732 57.4385 44.4965C53.8718 45.6449 52.3935 45.8324 47.2546 45.8324C43.3594 45.8324 42.1158 45.7386 39.9805 45.2933C32.2604 43.7466 25.3382 40.9577 19.4015 36.9735C15.0839 34.0909 12.5028 31.7004 9.80427 27.9975C6.80073 23.9196 4.36038 17.2403 3.72682 11.475C3.37485 8.1471 3.1402 7.32683 2.43624 7.13934C0.770217 6.71749 0.183578 7.77211 0.0193217 11.5219C-0.26226 18.5996 2.55356 27.1304 7.17619 33.1066C13.8403 41.7545 25.432 48.4103 38.901 51.2696C41.6465 51.8555 42.2566 51.9023 47.4893 51.9023C52.3935 51.9023 53.426 51.832 55.5144 51.3867C62.2723 49.9337 68.5375 46.6292 72.949 42.1998C76.0464 39.1296 78.1113 36.2939 79.8946 32.7081C82.1942 28.0912 83.5317 23.3103 84.2591 17.17C84.3999 15.8576 84.6111 14.7795 84.7284 14.7795C84.8223 14.7795 85.4559 15.1311 86.1364 15.5763C88.037 16.7716 90.3835 17.8965 93.5748 19.0918C96.813 20.3339 97.3996 20.287 96.4141 18.9512C94.9123 16.9122 90.055 11.5219 87.1219 8.63926C84.0949 5.66288 83.8368 5.33477 83.5552 4.1864C83.3909 3.48332 83.0155 2.68649 82.6401 2.31151C82.0065 1.6553 80.4109 1.04595 79.9885 1.30375C79.8712 1.37406 79.2845 1.11626 78.6744 0.717845C77.2431 -0.172727 75.7413 -0.243024 74.568 0.553803Z"
fill="currentColor"
/>
</svg>
</span>
</div>
<a
aria-label="Follow Jhey"
class="bear-link"
href="https://twitter.com/intent/follow?screen_name=jh3yy"
target="_blank"
rel="noreferrer noopener"
>
<svg
class="w-9"
viewBox="0 0 969 955"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="161.191"
cy="320.191"
r="133.191"
stroke="currentColor"
stroke-width="20"
></circle>
<circle
cx="806.809"
cy="320.191"
r="133.191"
stroke="currentColor"
stroke-width="20"
></circle>
<circle
cx="695.019"
cy="587.733"
r="31.4016"
fill="currentColor"
></circle>
<circle
cx="272.981"
cy="587.733"
r="31.4016"
fill="currentColor"
></circle>
<path
d="M564.388 712.083C564.388 743.994 526.035 779.911 483.372 779.911C440.709 779.911 402.356 743.994 402.356 712.083C402.356 680.173 440.709 664.353 483.372 664.353C526.035 664.353 564.388 680.173 564.388 712.083Z"
fill="currentColor"
></path>
<rect
x="310.42"
y="448.31"
width="343.468"
height="51.4986"
fill="#FF1E1E"
></rect>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M745.643 288.24C815.368 344.185 854.539 432.623 854.539 511.741H614.938V454.652C614.938 433.113 597.477 415.652 575.938 415.652H388.37C366.831 415.652 349.37 433.113 349.37 454.652V511.741L110.949 511.741C110.949 432.623 150.12 344.185 219.845 288.24C289.57 232.295 384.138 200.865 482.744 200.865C581.35 200.865 675.918 232.295 745.643 288.24Z"
fill="currentColor"
></path>
</svg>
</a>
import { Pane } from 'https://cdn.skypack.dev/[email protected]'
import gsap from 'https://cdn.skypack.dev/[email protected]'
import { Draggable } from 'https://cdn.skypack.dev/[email protected]/Draggable'
import InertiaPlugin from 'https://cdn.skypack.dev/[email protected]/InertiaPlugin'
import MorphSVGPlugin from 'https://cdn.skypack.dev/[email protected]/MorphSVGPlugin'
import Flip from 'https://cdn.skypack.dev/[email protected]/Flip'
gsap.registerPlugin(Draggable, InertiaPlugin, Flip, MorphSVGPlugin)
const config = {
theme: 'light',
proximity: 120,
debug: false,
duration: 0.35,
rollback: 0.2,
timeScale: 1,
}
const ctrl = new Pane({
title: 'config',
expanded: false,
})
const update = () => {
document.documentElement.dataset.theme = config.theme
document.documentElement.dataset.debug = config.debug
document.documentElement.style.setProperty(
'--snap-proximity',
config.proximity
)
document.documentElement.style.setProperty(
'--timescale',
1 / config.timeScale
)
}
const sync = (event) => {
if (
!document.startViewTransition ||
event.target.controller.view.labelElement.innerText !== 'theme'
)
return update()
document.startViewTransition(() => update())
}
const spots = ctrl.addFolder({ title: 'hot spots', expanded: false })
spots.addBinding(config, 'proximity', {
min: 0,
max: 300,
step: 1,
label: 'proximity',
})
ctrl.addBinding(config, 'duration', {
min: 0.1,
max: 5,
step: 0.01,
label: 'morph duration',
})
ctrl.addBinding(config, 'timeScale', {
min: 0.1,
max: 2,
step: 0.01,
label: 'playback timeScale',
})
spots.addBinding(config, 'debug', {
label: 'debug',
})
ctrl.addBinding(config, 'theme', {
label: 'theme',
options: {
System: 'system',
Light: 'light',
Dark: 'dark',
},
})
ctrl.on('change', sync)
update()
// END TWEAKPANE STUFF
// do GSAP stuff
const button = document.querySelector('[aria-label^="Sign"]')
const targets = document.querySelectorAll('.target')
const getClosestPoint = (target, maxDistance = Number.POSITIVE_INFINITY) => {
if (maxDistance !== Number.POSITIVE_INFINITY)
document.documentElement.style.setProperty('--snap-proximity', maxDistance)
let closest = null
let minDistance = Number.POSITIVE_INFINITY
const coordinates = Array.from(targets).map((el) => {
const rect = el.getBoundingClientRect()
return {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
element: el,
}
})
for (const coord of coordinates) {
const dx = Math.min(window.innerWidth, Math.max(0, target.x)) - coord.x
const dy = Math.min(window.innerHeight, Math.max(0, target.y)) - coord.y
const distance = Math.hypot(dx, dy) // Euclidean distance
if (distance < minDistance) {
minDistance = distance
closest = coord
}
}
if (minDistance <= maxDistance) {
return {
coordinate: closest,
distance: minDistance,
}
}
// If no point is within maxDistance
return null
}
const RESISTANCE_PIXELS = 80
const DEFAULT_RESISTANCE = 0.75
const END_RESISTANCE = 0
gsap.set(button, {
x: window.innerWidth * 0.5,
y: window.innerHeight * 0.5,
yPercent: -50,
xPercent: -50,
})
// make magnetic hotspots draggable
for (const target of targets) {
Draggable.create(target, {
inertia: false,
type: 'x,y',
})
}
Draggable.create(button, {
inertia: true,
allowContextMenu: true,
allowEventDefault: true,
type: 'x,y',
dragResistance: DEFAULT_RESISTANCE,
resistance: 1800,
snap: {
points: function (point) {
const { isDragging, __unlocked, startX, startY } = this
const closestPoint = getClosestPoint(point, config.proximity)
if (!isDragging && !__unlocked) {
return { x: startX, y: startY }
} else if (__unlocked && closestPoint) {
for (const t of targets) t.dataset.active = false
closestPoint.coordinate.element.dataset.active = true
return closestPoint.coordinate
}
return point
},
},
bounds: '.targets',
onDragStart: function (event) {
const bounds = this.target.getBoundingClientRect()
const currentPoint = {
x: bounds.left + bounds.width / 2,
y: bounds.top + bounds.height / 2,
}
this.__start = currentPoint
this.dragResistance = DEFAULT_RESISTANCE
this.__unlocked = false
document.documentElement.dataset.dragging = true
},
onDrag: function (event) {
const { startX, startY, x, y } = this
const distance = Math.hypot(x - startX, y - startY)
const newResistance = gsap.utils.clamp(
END_RESISTANCE,
DEFAULT_RESISTANCE,
gsap.utils.mapRange(
0,
RESISTANCE_PIXELS,
DEFAULT_RESISTANCE,
END_RESISTANCE
)(Math.abs(distance))
)
if (!this.__unlocked) this.dragResistance = newResistance
if (!this.__unlocked && newResistance === END_RESISTANCE) {
this.__unlocked = true
document.querySelector('.arrow--instruction').style.opacity = 0
for (const t of targets) t.dataset.active = false
}
},
onDragEnd: function () {
this.dragResistance = DEFAULT_RESISTANCE
this.__unlocked = false
},
onRelease: () => {
document.documentElement.dataset.dragging = false
},
onThrowComplete: function () {
this.dragResistance = DEFAULT_RESISTANCE
this.__unlocked = false
},
})
// do some Popover stuff...
const pop = document.querySelector('[popover]')
let f
let signoff
let popDrag
const closePopover = async (event) => {
if ((event && event.key === 'Escape') || event === undefined) {
pop.dataset.closing = 'true'
if (popDrag) popDrag[0].kill()
// need to flip down to the button location which could be "tricky"
// first ascertain if you're next to a hotspot
let closerBounds
const pos = pop.getBoundingClientRect()
const center = {
x: pos.x + pos.width * 0.5,
y: pos.y + pos.height * 0.5,
}
const {
x: buttonX,
y: buttonY,
width: buttonWidth,
height: buttonHeight,
} = button.getBoundingClientRect()
const closest = getClosestPoint(center, config.proximity)
if (closest) {
closerBounds = closest.coordinate.element.getBoundingClientRect()
gsap.set(button, {
opacity: 0,
x: closest.coordinate.x,
y: closest.coordinate.y,
})
}
const state = Flip.getState(
[
pop,
'.placeholder',
'.placeholder > svg',
'[popover] .popover__content',
],
{
nested: true,
props: 'borderRadius, scale, opacity, filter, backgroundColor, color',
}
)
// now set the popover attributes
gsap.set(pop, {
width: buttonWidth,
height: buttonHeight,
top: closest
? closerBounds.top + closerBounds.height * 0.5
: buttonY + buttonHeight * 0.5,
left: closest
? closerBounds.left + closerBounds.width * 0.5
: buttonX + buttonWidth * 0.5,
opacity: 1,
borderRadius: '21px',
x: 0,
y: 0,
xPercent: -50,
yPercent: -50,
backgroundColor:
pop.dataset.signed === 'true'
? 'hsl(140 80% 90%)'
: getComputedStyle(document.body).color,
color:
pop.dataset.signed === 'true'
? 'hsl(140 90% 30%)'
: getComputedStyle(document.body).backgroundColor,
})
gsap.set('.placeholder > svg', {
width: 20,
height: 20,
y: 0,
opacity: 1,
})
gsap.set('[popover] .popover__content', {
y: 0,
opacity: 0,
filter: 'blur(4px)',
})
// try it
const tl = gsap.timeline({ paused: true })
tl.add(
Flip.from(state, {
duration: config.duration,
nested: true,
// delay: 1,
ease: 'power2.inOut',
})
)
if (pop.dataset.signed === 'true') {
tl.to(
'#morph',
{
morphSVG: '#check',
duration: config.duration,
ease: 'power2.inOut',
},
0
)
}
await tl.play()
gsap.set(button, { opacity: 1, boxShadow: 'var(--shadow)' })
pop.hidePopover()
}
}
const handleOffClick = (event) => {
if (!event.target.closest('[popover]')) closePopover()
}
const openPopover = async () => {
gsap.set(button, {
boxShadow: 'unset',
})
// we need to work out the correct position for x/y
const {
x: buttonX,
y: buttonY,
width: buttonWidth,
height: buttonHeight,
} = button.getBoundingClientRect()
const center = {
x: buttonX + buttonWidth * 0.5,
y: buttonY + buttonHeight * 0.5,
}
const {
x: popX,
y: popY,
width: popWidth,
height: popHeight,
} = pop.getBoundingClientRect()
let vertical = 'center'
if (center.y < popHeight * 0.5 + 24) vertical = 'bottom'
if (center.y > window.innerHeight - (popHeight * 0.5 + 24)) vertical = 'top'
let horizontal = 'center'
if (center.x < popWidth * 0.5) horizontal = 'left'
if (center.x > window.innerWidth - popWidth * 0.5) horizontal = 'right'
const result = `vertical: ${vertical}; horizontal: ${horizontal};`
// now do the FLIPPING stuff
gsap.set(pop, {
width: buttonWidth,
height: buttonHeight,
top: buttonY,
left: buttonX,
opacity: 1,
borderRadius: '22px',
backgroundColor:
pop.dataset.signed === 'true'
? 'hsl(140 80% 90%)'
: getComputedStyle(document.body).color,
color:
pop.dataset.signed === 'true'
? 'hsl(140 90% 30%)'
: getComputedStyle(document.body).backgroundColor,
})
const state = Flip.getState(
[pop, '.placeholder', '.placeholder > svg', '[popover] .popover__content'],
{
nested: true,
props: 'borderRadius, scale, opacity, filter, backgroundColor, color, y',
}
)
// assume central position first
const position = {
left: center.x,
top: center.y,
xPercent: -50,
yPercent: -50,
}
if (horizontal === 'left') {
position.right = 'unset'
position.left = '1.5rem'
position.xPercent = 0
}
if (horizontal === 'right') {
position.left = 'unset'
position.right = '1.5rem'
position.xPercent = 0
}
if (vertical === 'bottom') {
position.top = '1.5rem'
position.bottom = 'unset'
position.yPercent = 0
}
if (vertical === 'top') {
position.bottom = '1.5rem'
position.top = 'unset'
position.yPercent = 0
}
gsap.set(pop, { clearProps: 'width,height' })
gsap.set(pop, {
...position,
opacity: 1,
borderRadius: '6px',
backgroundColor: getComputedStyle(document.body).backgroundColor,
color: getComputedStyle(document.body).color,
})
gsap.set('.placeholder > svg', {
width: 16,
height: 16,
y: -6,
opacity: 0.5,
})
gsap.set('[popover] .popover__content', {
y: 0,
opacity: 1,
filter: 'blur(0px)',
background: '#0000',
})
window.addEventListener('keydown', closePopover)
window.addEventListener('click', handleOffClick)
const tl = gsap.timeline({ paused: true })
tl.add(
Flip.from(state, {
// duration: 0.36,
duration: config.duration,
nested: true,
// delay: 10,
ease: 'power2.inOut',
})
)
if (pop.dataset.signed === 'true') {
tl.to(
'#morph',
{
morphSVG: '#pen',
duration: config.duration,
ease: 'power2.inOut',
},
0
)
}
await tl.play()
// calibrate signature canvas
calibrateCanvas()
// set up event listeners for light dismiss etc.
popDrag = Draggable.create(pop, {
dragClickables: false,
inertia: true,
allowContextMenu: true,
type: 'x,y',
bounds: '.targets',
onDrag: () => {
f = undefined
const pos = pop.getBoundingClientRect()
const center = {
x: pos.x + pos.width * 0.5,
y: pos.y + pos.height * 0.5,
xPercent: -50,
yPercent: -50,
}
gsap.set(button, {
x: center.x,
y: center.y,
xPercent: -50,
yPercent: -50,
})
},
onThrowUpdate: () => {
f = undefined
const pos = pop.getBoundingClientRect()
const center = {
x: pos.x + pos.width * 0.5,
y: pos.y + pos.height * 0.5,
}
gsap.set(button, {
x: center.x,
y: center.y,
})
},
})
}
pop.addEventListener('toggle', async (event) => {
if (event.newState === 'open') {
openPopover()
} else {
// closing
gsap.set([pop, '.placeholder > svg', '[popover] .popover__content'], {
clearProps: 'all',
})
gsap.set(button, {
opacity: 1,
})
pop.dataset.closing = 'false'
window.removeEventListener('keydown', closePopover)
window.removeEventListener('click', handleOffClick)
}
})
// Signature handling - this is where it gets more serious
const erase = document.querySelector('button.erase')
const hold = document.querySelector('button.hold')
// const signature = document.querySelector('.signature')
// const signatureElement = signature.querySelector('svg')
// const signaturePath = signature.querySelector('path')
const animated = document.querySelector('.animated')
const animatedElement = animated.querySelector('svg')
const animatedPath = animated.querySelector('path')
// set up the canvas
const canvas = document.querySelector('[popover] canvas')
const ctx = canvas.getContext('2d')
const DPR = window.devicePixelRatio || 1
const calibrateCanvas = () => {
if (strokes.length === 0) {
// Get the canvas size
const rect = canvas.getBoundingClientRect()
// Set the canvas size in actual pixels (scaled by DPR)
canvas.width = Math.floor(rect.width * DPR)
canvas.height = Math.floor(rect.height * DPR)
// Scale the canvas context by DPR
ctx.scale(DPR, DPR)
// Set up canvas
ctx.lineWidth = 2.5
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.strokeStyle = 'canvasText'
}
}
// kick off rAF using ticker with pointer event
gsap.set(canvas, { touchAction: 'none' })
const strokes = []
let minTime = Number.POSITIVE_INFINITY
let maxTime = Number.NEGATIVE_INFINITY
let signDuration
const getPoint = ({ x, y }) => {
const { left, top } = canvas.getBoundingClientRect()
const time = Date.now()
minTime = Math.min(minTime, time)
maxTime = Math.max(maxTime, time)
signDuration = (maxTime - minTime) / 1000
gsap.set(pop, {
'--sign-on': signDuration,
})
if (pop.dataset.valid !== 'true' && isValidSignature())
pop.dataset.valid = 'true'
return {
x: x - left,
y: y - top,
time,
}
}
const generateSVGPath = () => {
let path = ''
if (strokes.length === 0) return path
for (const stroke of strokes) {
const points = stroke.points
if (points.length > 0) {
path += `M ${points[0].x} ${points[0].y} `
for (let i = 1; i < points.length; i++) {
path += `L ${points[i].x} ${points[i].y} `
}
}
}
return path
}
const render = () => {
for (const stroke of strokes) {
const points = stroke.points
if (points.length > 0) {
ctx.beginPath()
ctx.moveTo(points[0].x, points[0].y)
for (let i = 1; i < points.length; i++) {
ctx.lineTo(points[i].x, points[i].y)
}
ctx.stroke()
}
}
}
const sign = (event) => {
const currentStroke = strokes[strokes.length - 1]
currentStroke.points.push(getPoint(event))
}
const startSigning = (event) => {
gsap.ticker.add(render)
strokes.push({
points: [getPoint(event)],
})
canvas.addEventListener('pointermove', sign)
}
const stopSigning = (event) => {
gsap.ticker.remove(render)
canvas.removeEventListener('pointermove', sign)
// render the SVG
const path = generateSVGPath()
animatedPath.setAttribute('d', path)
if (signDuration !== undefined) {
hold.setAttribute(
'aria-label',
`Hold for ${signDuration.toFixed(2)} seconds to confirm signature`
)
}
// const rect = canvas.getBoundingClientRect()
// signatureElement.setAttribute('viewBox', `0 0 ${rect.width} ${rect.height}`)
// signaturePath.setAttribute('d', generateSVGPath())
}
const handleTouchOff = () => {
// now are we done?
console.info('running this now')
pop.dataset.signing = 'false'
if (signoff) {
if (signoff.progress() !== 0) pop.dataset.reversing = 'true'
signoff.timeScale(2)
signoff.pause()
signoff.reverse()
}
// only need this for the CSS method to check validity
// if (getComputedStyle(animatedPath).strokeDashoffset === '0px') {
// pop.dataset.signed = 'true'
// hold.setAttribute('aria-label', 'Signed')
// gsap.set('[popover] button', {
// attr: {
// disabled: 'true',
// },
// })
// gsap.set(animatedPath, { strokeDashoffset: 0 })
// gsap.set('.toggle svg path:first-of-type', { opacity: 0 })
// gsap.set('.toggle svg path:last-of-type', { opacity: 1 })
// }
}
const clearSignature = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height)
strokes.length = 0
gsap.set(animatedPath, {
attr: {
d: '',
},
})
pop.dataset.valid = 'false'
minTime = Number.POSITIVE_INFINITY
maxTime = Number.NEGATIVE_INFINITY
}
let all
const replaySignature = async () => {
// check for validity here...
// but this could also be done whilst it's being drawn to set disabled?
if (!isValidSignature()) return
pop.dataset.signing = 'true'
// ideally you create a tween that you play/reverse at different rates here...
// Prepare animation data
all = []
console.info({ all })
let minTime = Number.POSITIVE_INFINITY
let maxTime = Number.NEGATIVE_INFINITY
const rect = canvas.getBoundingClientRect()
animatedElement.setAttribute('viewBox', `0 0 ${rect.width} ${rect.height}`)
// Collect all points with timing data and mark new strokes
for (const stroke of strokes) {
stroke.points.forEach((point, pointIndex) => {
all.push({
x: point.x,
y: point.y,
time: point.time,
relativeTime: point.time - strokes[0].points[0].time,
isNewStroke: pointIndex === 0, // Mark the first point of each stroke
})
// Track min and max times
minTime = Math.min(minTime, point.time)
maxTime = Math.max(maxTime, point.time)
})
}
// Sort points by time
all.sort((a, b) => a.time - b.time)
signoff = gsap.timeline({
paused: true,
})
let def = ''
for (const point of all) {
def += ` ${point.isNewStroke ? 'M' : 'L'} ${point.x} ${point.y} `
if (point.isNewStroke) {
signoff.set(
animatedPath,
{
attr: {
d: def,
},
},
point.relativeTime / 1000
)
} else {
// this would be great as a .to morphSVG but it can't handle it
signoff.set(
animatedPath,
{
attr: {
d: def,
},
},
point.relativeTime / 1000
)
}
}
signoff.timeScale(config.timeScale)
await signoff.play()
pop.dataset.signing = 'false'
pop.dataset.reversing = 'false'
if (signoff.progress() === 1) {
pop.dataset.signed = 'true'
hold.setAttribute('aria-label', 'Signed')
gsap.set('[popover] button', {
attr: {
disabled: 'true',
},
})
gsap.set('.toggle svg path:first-of-type', { opacity: 0 })
gsap.set('.toggle svg path:last-of-type', { opacity: 1 })
} else {
console.info('did not confirm')
}
}
// backup replay that just does a basic draw
// const replaySignature = async () => {
// if (!isValidSignature()) return
// try {
// await Promise.all(animatedPath.getAnimations().map((t) => t.finished))
// if (getComputedStyle(animatedPath).strokeDashoffset === '0px') {
// pop.dataset.signed = 'true'
// hold.setAttribute('aria-label', 'Signed')
// gsap.set('[popover] button', {
// attr: {
// disabled: 'true',
// },
// })
// gsap.set(animatedPath, { strokeDashoffset: 0 })
// gsap.set('.toggle svg path:first-of-type', { opacity: 0 })
// gsap.set('.toggle svg path:last-of-type', { opacity: 1 })
// } else {
// hold.addEventListener('keydown', handleKeyReplay, { once: true })
// }
// } catch (err) {
// console.info('User did not confirm signature')
// hold.addEventListener('keydown', handleKeyReplay, { once: true })
// }
// // DO THIS ONCE TRANSITION HAS FINISHED
// }
const handleKeyReplay = (event) => {
if (event.code === 'Space') {
replaySignature()
}
}
canvas.addEventListener('pointerdown', startSigning)
canvas.addEventListener('pointerup', stopSigning)
erase.addEventListener('click', clearSignature)
hold.addEventListener('pointerdown', replaySignature)
hold.addEventListener('pointerup', handleTouchOff)
hold.addEventListener('keydown', handleKeyReplay, { once: true })
// check signature validity
const isValidSignature = () => {
// Minimum requirements
const MIN_POINTS = 50 // Minimum number of points across all strokes
const MIN_DISTANCE = 150 // Minimum distance covered in pixels
// Count total points
let totalPoints = 0
let totalDistance = 0
for (const stroke of strokes) {
const points = stroke.points
totalPoints += points.length
// Calculate distance covered in this stroke
for (let i = 1; i < points.length; i++) {
const dx = points[i].x - points[i - 1].x
const dy = points[i].y - points[i - 1].y
totalDistance += Math.sqrt(dx * dx + dy * dy)
}
}
return totalPoints >= MIN_POINTS && totalDistance >= MIN_DISTANCE
}
ctrl.addButton({ title: 'reset signature' }).on('click', () => {
pop.dataset.signed = 'false'
strokes.length = 0
clearSignature()
for (const button of document.querySelectorAll('[popover] button')) {
button.removeAttribute('disabled')
}
gsap.set(animatedPath, { clearProps: 'all' })
hold.setAttribute('aria-label', 'Hold to confirm')
gsap.set('.toggle svg path:first-of-type', { clearProps: 'all' })
gsap.set('.toggle svg path:last-of-type', { opacity: 0 })
gsap.set('#morph', { morphSVG: '#pen' })
hold.addEventListener('keydown', handleKeyReplay, { once: true })
})
@import url('https://unpkg.com/normalize.css') layer(normalize);
/* @import 'normalize.css' layer(normalize); */
@import url('https://fonts.googleapis.com/css2?family=Gloria+Hallelujah&family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap');
@layer normalize, base, demo, popover, signing;
@layer signing {
:root {
--transition: 0.26s;
--delay: 0.5s;
}
.animated {
color: canvasText;
z-index: 2;
position: relative;
}
.animated svg path {
/* YOU ONLY NEED THIS IF YOU RELY ON STROKE_DASH and PATHLENGTH */
/* stroke-dasharray: 1;
stroke-dashoffset: 1; */
/* transition: stroke-dashoffset var(--transition) ease-out; */
}
[popover][data-valid='true']:has(.hold:active) .animated svg path {
/* YOU ONLY NEED THIS IF YOU RELY ON STROKE_DASH and PATHLENGTH */
/* stroke-dashoffset: 0;
transition-duration: calc(var(--sign-on, 0.2) * 1s); */
}
:is(.animated, .signature) {
width: var(--surface-width);
pointer-events: none;
svg {
width: 100%;
path {
stroke-width: 3px;
}
}
}
.animated {
opacity: 0;
}
[popover][data-signed='true'] .animated,
[popover][data-reversing='true'] .animated,
[popover][data-signing='true'] .animated {
opacity: 1;
}
[popover][data-signed='true'] {
:is(canvas, [popover] button, .canvas) {
pointer-events: none;
}
.status {
translate: 0 100%;
clip-path: inset(-100% 0 0 0);
}
.secondary button span {
translate: 0 100%;
}
}
.secondary button span {
transition: translate var(--transition) var(--delay) ease-out;
}
[popover] canvas {
transition: opacity var(--transition) var(--transition) ease-out;
}
[popover][data-valid='true'][data-reversing='true'] canvas,
[popover][data-valid='true'][data-signing='true'] canvas {
opacity: 0.1;
transition-delay: 0s;
}
[popover][data-valid='true'][data-signing='true'] .status {
transition-duration: calc((var(--sign-on, 0.2) * var(--timescale, 1)) * 1s),
var(--transition);
clip-path: inset(-100% 0 0 0);
}
:root:has([data-signed='true']) .toggle {
background: hsl(140 80% 90%);
color: hsl(140 90% 30%);
outline-color: hsl(140 80% 50%);
}
.hold {
padding: 0;
border: 0;
border-radius: 4px;
user-select: none;
height: 32px;
position: relative;
overflow: hidden;
outline-color: canvasText;
user-select: none;
-webkit-tap-highlight-color: #0000;
touch-action: none;
.status {
display: inline-block;
position: absolute;
inset: 0;
color: hsl(140 90% 30%);
clip-path: inset(-100% 100% 0 0);
transition: clip-path var(--transition) ease-out,
translate var(--transition) var(--delay) ease-out;
}
.track {
background: hsl(140 80% 90%);
width: 100%;
height: 200%;
display: inline-block;
display: grid;
grid-template-rows: 1fr 1fr;
position: absolute;
bottom: 0;
span {
display: grid;
place-items: center;
}
}
> span:last-of-type {
height: 100%;
display: flex;
align-items: center;
padding: 0.25rem 0.75rem;
}
}
}
@layer popover {
:root {
--toggle-size: 44px;
--surface-width: 380px;
--surface-height: 200px;
--shadow-color: 0 0% 0%;
--shadow: 0px 0.5px 0.6px hsl(var(--shadow-color) / 0.07),
0px 1.7px 2.2px -0.4px hsl(var(--shadow-color) / 0.09),
0px 3.2px 4.1px -0.7px hsl(var(--shadow-color) / 0.11),
-0.1px 6.1px 7.8px -1.1px hsl(var(--shadow-color) / 0.13),
-0.2px 11.2px 14.3px -1.5px hsl(var(--shadow-color) / 0.15);
}
[data-clickable] {
cursor: default;
}
.placeholder {
width: var(--toggle-size);
aspect-ratio: 1;
display: grid;
place-items: center;
top: 0;
left: 0;
pointer-events: none;
position: absolute;
svg {
width: 20px;
height: 20px;
}
}
[popover] {
width: var(--surface-width);
max-width: calc(100vw - 2rem);
height: var(--surface-height);
height: fit-content;
border-radius: 6px;
margin: 0;
inset: unset;
opacity: 0;
padding: 0;
overflow: hidden;
/* border-color: light-dark(hsl(0 0% 75%), hsl(0 0% 60%));
border-width: 1px; */
border: 0;
background: canvasText;
color: canvas;
box-shadow: var(--shadow);
.popover__content {
padding: 0.5rem 0;
display: grid;
grid-template-rows: auto 1fr auto;
height: 100%;
width: 100%;
filter: blur(4px);
opacity: 0;
width: var(--surface-width);
max-width: calc(100vw - 2rem);
border-radius: inherit;
font-size: 0.875rem;
button {
cursor: pointer;
}
header {
display: flex;
gap: 0 0.5rem;
padding-inline: 0.875rem;
color: light-dark(hsl(0 0% 50%), hsl(0 0% 80%));
svg {
width: 16px;
height: 16px;
opacity: 0;
}
}
footer {
padding-inline: 0.5rem;
display: flex;
justify-content: space-between;
.secondary {
display: flex;
gap: 0.25rem;
button {
display: grid;
padding: 0;
place-items: center;
width: 32px;
aspect-ratio: 1;
border: 0;
background: #0000;
color: color-mix(in oklch, canvasText, #0000);
position: relative;
transition: color 0.2s ease-out;
span {
height: 100%;
width: 100%;
display: grid;
place-items: center;
}
&::after {
content: '';
opacity: 0;
position: absolute;
inset: 0;
border-radius: 6px;
background: color-mix(in oklch, canvasText, #0000 94%);
transition: opacity 0.2s ease-out;
}
&:is(:hover, :focus-visible) {
color: canvasText;
&::after {
opacity: 1;
}
}
}
svg {
width: 18px;
height: 18px;
}
}
}
.canvas {
position: relative;
height: 130px;
}
canvas {
flex: 1;
width: 100%;
height: 100%;
position: absolute;
inset: 0;
}
}
&::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
border: 1px solid light-dark(hsl(0 0% 80%), hsl(0 0% 50%));
}
&:popover-open {
display: flex;
flex-direction: column;
}
}
}
@layer demo {
/* instruction arrow */
.arrow {
display: inline-block;
opacity: 0.8;
position: fixed;
font-size: 0.875rem;
font-family: 'Gloria Hallelujah', cursive;
transition: opacity 0.26s ease-out;
&.arrow--instruction {
top: 50%;
left: 50%;
translate: -160% -70%;
rotate: -10deg;
svg {
color: hsl(0 0% 65%);
scale: 1 1;
top: 130%;
rotate: 35deg;
left: 10%;
width: 60%;
translate: 120% 60%;
position: absolute;
}
}
span {
display: inline-block;
white-space: nowrap;
}
}
[data-dragging='true'] {
cursor: grabbing;
}
[data-debug='true'] .target {
opacity: 1;
}
/* the toggle size */
.toggle {
width: var(--toggle-size);
aspect-ratio: 1;
border-radius: 50%;
display: grid;
outline-color: canvasText;
place-items: center;
padding: 0;
position: fixed;
top: 0;
left: 0;
color: canvas;
background: canvasText;
border-style: solid;
border-color: color-mix(in oklch, canvasText, #0000 85%);
border-width: 1px;
box-shadow: var(--shadow);
svg {
width: 20px;
height: 20px;
}
}
/* magnetic hotspots */
.targets {
position: fixed;
inset: 1.5rem;
pointer-events: none;
}
.target {
pointer-events: all;
width: var(--toggle-size);
aspect-ratio: 1;
background: hsl(var(--hue, 10) 80% 50% / 0.2);
border-radius: 50%;
opacity: 0;
transition: opacity 0.26s ease-out;
position: fixed;
&[data-active='true'] {
--hue: 150;
}
&::after {
content: '';
width: max(var(--toggle-size), var(--snap-proximity) * 2px);
aspect-ratio: 1;
border-radius: 50%;
background: hsl(var(--hue, 10) 80% 50% / 0.1);
outline: 2px dashed hsl(var(--hue, 10) 80% 50% / 0.5);
position: absolute;
top: 50%;
left: 50%;
translate: -50% -50%;
pointer-events: none;
}
&.target--north {
top: 1.5rem;
left: 50%;
translate: -50% 0%;
}
&.target--west {
left: 1.5rem;
top: 50%;
translate: 0% -50%;
}
&.target--south-west {
bottom: 1.5rem;
left: 1.5rem;
}
&.target--south {
bottom: 1.5rem;
left: 50%;
translate: -50% 0;
}
&.target--east {
top: 50%;
right: 1.5rem;
translate: 0 -50%;
}
&.target--south-east {
bottom: 1.5rem;
right: 1.5rem;
}
}
}
@layer base {
:root {
--font-size-min: 16;
--font-size-max: 20;
--font-ratio-min: 1.2;
--font-ratio-max: 1.33;
--font-width-min: 375;
--font-width-max: 1500;
}
html {
color-scheme: light dark;
}
[data-theme='light'] {
color-scheme: light only;
}
[data-theme='dark'] {
color-scheme: dark only;
}
:where(.fluid) {
--fluid-min: calc(
var(--font-size-min) * pow(var(--font-ratio-min), var(--font-level, 0))
);
--fluid-max: calc(
var(--font-size-max) * pow(var(--font-ratio-max), var(--font-level, 0))
);
--fluid-preferred: calc(
(var(--fluid-max) - var(--fluid-min)) /
(var(--font-width-max) - var(--font-width-min))
);
--fluid-type: clamp(
(var(--fluid-min) / 16) * 1rem,
((var(--fluid-min) / 16) * 1rem) -
(((var(--fluid-preferred) * var(--font-width-min)) / 16) * 1rem) +
(var(--fluid-preferred) * var(--variable-unit, 100vi)),
(var(--fluid-max) / 16) * 1rem
);
font-size: var(--fluid-type);
}
*,
*:after,
*:before {
box-sizing: border-box;
}
body {
background: light-dark(#fff, #000);
display: grid;
place-items: center;
min-height: 100vh;
font-family: 'Inter', 'SF Pro Text', 'SF Pro Icons', 'AOS Icons',
'Helvetica Neue', Helvetica, Arial, sans-serif, system-ui;
}
body::before {
--size: 45px;
--line: color-mix(in hsl, canvasText, transparent 80%);
content: '';
height: 100vh;
width: 100vw;
position: fixed;
background: linear-gradient(
90deg,
var(--line) 1px,
transparent 1px var(--size)
)
calc(var(--size) * 0.36) 50% / var(--size) var(--size),
linear-gradient(var(--line) 1px, transparent 1px var(--size)) 0%
calc(var(--size) * 0.32) / var(--size) var(--size);
mask: linear-gradient(-20deg, transparent 50%, white);
top: 0;
transform-style: flat;
pointer-events: none;
z-index: -1;
}
.bear-link {
color: canvasText;
position: fixed;
top: 1rem;
left: 1rem;
width: 48px;
aspect-ratio: 1;
display: grid;
place-items: center;
opacity: 0.8;
}
:where(.x-link, .bear-link):is(:hover, :focus-visible) {
opacity: 1;
}
.bear-link svg {
width: 75%;
}
/* Utilities */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
}
div.tp-dfwv {
width: 260px;
word-break: auto-phrase;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment