Repository navigation
Expand file tree
/
Copy pathcode.html
More file actions
259 lines (242 loc) · 15.8 KB
/
Copy pathcode.html
File metadata and controls
259 lines (242 loc) · 15.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Hungry Delete — press it, it eats</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@800&display=swap">
<style>
:root{--paper:#efe8d8;--ink:#14141a;--lime:#c8ff3a;--blue:#2c47ff;box-sizing:border-box;padding-top:env(safe-area-inset-top,0px);padding-bottom:env(safe-area-inset-bottom,0px)}
@property --p{syntax:'<number>';inherits:true;initial-value:0}
@property --fx{syntax:'<length>';inherits:true;initial-value:0px}
*{box-sizing:border-box;margin:0}
html,body{height:100%}
body{background:var(--paper);color:var(--ink);overflow:hidden;font-family:"Bricolage Grotesque",ui-rounded,system-ui,sans-serif;-webkit-font-smoothing:antialiased;
background-image:radial-gradient(rgba(20,20,26,.16) 1.2px,transparent 1.3px);background-size:22px 22px}
.scene{position:fixed;left:50%;top:50%;width:720px;height:640px;margin:-320px 0 0 -360px;transform:scale(var(--k,1))}
.kick{display:block;text-align:center;font:700 14px/1 system-ui,sans-serif;letter-spacing:.22em;text-transform:uppercase;opacity:.55;margin-bottom:14px}
.title{text-align:center;font-size:76px;line-height:.95;font-weight:800;letter-spacing:-3px}
.title em{font-style:normal;background:var(--lime);padding:0 14px;border:4px solid var(--ink);border-radius:18px;display:inline-block;transform:rotate(-2.5deg);box-shadow:5px 5px 0 var(--ink)}
.sub{text-align:center;margin-top:18px;font:600 18px/1.3 system-ui,sans-serif;opacity:.7}
.stage{position:absolute;left:30px;right:30px;top:230px;height:340px;border-radius:30px;display:flex;align-items:center;justify-content:space-around;background:#fffaf0;border:4px solid var(--ink);box-shadow:10px 10px 0 var(--ink)}
.slot{display:flex;flex-direction:column;align-items:center;gap:30px}
.cap{font:700 13px/1 system-ui,sans-serif;letter-spacing:.14em;text-transform:uppercase;opacity:.5}
.bar{position:absolute;left:0;right:0;bottom:26px;display:flex;justify-content:center;align-items:center;gap:12px}
.chip{font:700 14px/1 system-ui,sans-serif;color:var(--ink);background:#fff;border:3px solid var(--ink);padding:10px 16px;border-radius:99px;cursor:pointer;box-shadow:3px 3px 0 var(--ink)}
.chip[aria-pressed="true"]{background:var(--lime)}
.count{font:800 15px/1 system-ui,sans-serif;padding:10px 16px;border:3px solid var(--ink);border-radius:99px;background:var(--ink);color:var(--lime);min-width:132px;text-align:center}
/* ── button ── */
.gulp{--e:cubic-bezier(.16,1,.3,1);--t:.44s;--rr:48px;--fg:var(--lime);--ring:var(--lime);--sh:20,20,26;
--face:linear-gradient(120deg,#2b2b36 0%,#1a1a22 55%,#0b0b10 100%);
position:relative;display:block;width:240px;height:96px;flex:none;--fx:0px;--ix:0px;--p:0;transition:--p .3s var(--e);color:var(--fg)}
.gulp[data-tone="blue"]{--fg:#fff;--ring:#7d8fff;--sh:44,71,255;--face:linear-gradient(120deg,#5a72ff 0%,#2c47ff 55%,#1427c4 100%)}
.gulp__body{position:absolute;inset:0;transform:translate(var(--mx,0px),var(--my,0px)) rotate(var(--rot,0deg));transition:transform .18s ease-out;
filter:drop-shadow(0 3px 0 rgba(var(--sh),.9)) drop-shadow(0 16px 18px rgba(var(--sh),.28))}
.gulp[data-state="idle"] .gulp__btn:active{transform:scale(.97)}
.gulp__face,.gulp__btn{position:absolute;inset:0;clip-path:inset(0 var(--fx) round var(--rr));transition:clip-path var(--t) var(--e),transform .14s}
.gulp__face{background:linear-gradient(180deg,rgba(255,255,255,.22),rgba(255,255,255,0) 50%),var(--face)}
.gulp__btn{border:0;background:none;padding:0;font:inherit;color:inherit;cursor:pointer;outline:none;-webkit-tap-highlight-color:transparent}
.gulp:not([data-state="idle"]) .gulp__btn{cursor:default}
.gulp__focus{position:absolute;inset:-7px;border-radius:99px;border:3px dashed var(--ink);opacity:0;pointer-events:none}
.gulp:has(.gulp__btn:focus-visible) .gulp__focus{opacity:1}
.gulp__icon{position:absolute;left:28px;top:25px;width:46px;height:46px;overflow:visible;translate:var(--ix) 0;transition:translate var(--t) var(--e);
fill:none;stroke:currentColor;stroke-width:3.2;stroke-linecap:round;stroke-linejoin:round}
.gulp__can,.gulp__lid{transform-box:view-box;transition:rotate .34s cubic-bezier(.34,1.56,.64,1)}
.gulp__can{transform-origin:23px 42px}.gulp__lid{transform-origin:8px 9.4px}
.gulp__lvl{translate:0 calc(47px - var(--p) * 31px)}
.gulp__fill{fill:currentColor;stroke:none;animation:wave 1s linear infinite}
.gulp__liq{transform-origin:23px 30px;transform-box:view-box}
@keyframes wave{to{transform:translateX(24px)}}
@keyframes slosh{0%{rotate:0}20%{rotate:-8deg}45%{rotate:5deg}70%{rotate:-2deg}100%{rotate:0}}
@keyframes squash{0%{scale:1 1}30%{scale:1.08 .91}65%{scale:.99 1.03}100%{scale:1 1}}
.gulp__can[data-hit]{animation:squash .3s ease-out}.gulp__liq[data-hit]{animation:slosh .55s ease-out}
[data-label]{position:absolute;left:92px;top:0;height:100%;display:flex;align-items:center;font-weight:800;font-size:36px;line-height:1;letter-spacing:-.5px;white-space:nowrap;pointer-events:none;transition:opacity .3s ease-out .1s}
.gulp__ch{display:inline-block;will-change:transform}
.gulp__ring{position:absolute;left:55px;top:-17px;width:130px;height:130px;overflow:visible;pointer-events:none;opacity:0;fill:none;stroke-linecap:round}
.gulp__ring circle{stroke-dasharray:100}
.gulp__arc{stroke:var(--ink);stroke-width:5;stroke-dashoffset:72;transition:stroke-dashoffset .38s cubic-bezier(.45,0,.2,1)}
.gulp__seal{stroke:var(--ink);stroke-width:3;stroke-dashoffset:100}
.gulp__spin{transform-origin:65px 65px;transform-box:view-box}
@keyframes turn{to{rotate:360deg}}
.gulp[data-state="eat"] .gulp__lid{rotate:-52deg}
.gulp[data-state="eat"] .gulp__can{rotate:14deg}
.gulp[data-state="furl"],.gulp[data-state="pending"],.gulp[data-state="done"]{--fx:72px;--ix:69px}
.gulp[data-state="furl"] [data-label],.gulp[data-state="pending"] [data-label],.gulp[data-state="done"] [data-label]{opacity:0;transition:none}
.gulp[data-state="pending"] .gulp__ring,.gulp[data-state="done"] .gulp__ring{opacity:1}
.gulp[data-state="pending"] .gulp__spin,.gulp[data-state="done"] .gulp__spin{animation:turn .6s linear infinite}
.gulp[data-state="done"] .gulp__arc{stroke-dashoffset:0}
.gulp[data-state="done"] .gulp__seal{stroke-dashoffset:0;transition:stroke-dashoffset .3s cubic-bezier(.45,0,.2,1) .1s}
.gulp[data-state="done"]{transition:--p .3s cubic-bezier(.5,0,.75,0)}
.gulp[data-state="back"]{--fx:0px;--ix:0px}
.gulp[data-state="back"] [data-label]{opacity:1;transition:opacity .15s}
.gulp[data-state="back"] .gulp__ring{opacity:0;transform:scale(1.3);transition:opacity .34s,transform .4s var(--e)}
.gulp[data-state="back"] .gulp__arc,.gulp[data-state="back"] .gulp__seal{stroke-dashoffset:0}
.gulp[data-shake] .gulp__body{animation:shake .45s}
@keyframes shake{20%{translate:-9px 0}40%{translate:8px 0}60%{translate:-5px 0}80%{translate:3px 0}}
.spark{position:absolute;width:8px;height:8px;border-radius:2px;background:var(--ink);pointer-events:none;left:116px;top:44px}
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
@media (prefers-reduced-motion:reduce){.gulp__fill,.gulp__spin{animation:none!important}.gulp *{transition-duration:.01s!important}}
</style>
</head>
<body>
<main class="scene" id="scene">
<header>
<span class="kick">Micro-interaction № 02</span>
<h1 class="title">Hungry <em>Delete</em></h1>
<p class="sub">Press a button. The bin eats the word.</p>
</header>
<section class="stage" aria-label="Delete button demo">
<div class="slot"><span class="gulp" data-state="idle"></span><span class="cap">Succeeds</span></div>
<div class="slot"><span class="gulp" data-state="idle" data-tone="blue" data-fail="1"></span><span class="cap">Fails · spits it back</span></div>
<div class="bar">
<button class="chip" id="snd" aria-pressed="true">Sound on</button>
<span class="count" id="cnt" aria-live="polite">0 letters eaten</span>
</div>
</section>
</main>
<template id="tpl">
<span class="gulp__body">
<span class="gulp__face"></span>
<button class="gulp__btn" type="button" aria-label="Delete">
<svg class="gulp__icon" viewBox="0 0 46 46" aria-hidden="true">
<g class="gulp__can">
<clipPath id="CLIP"><path d="M13.5 14.4H32.5a1.5 1.5 0 0 1 1.5 1.7L31.8 39.4a3 3 0 0 1-3 2.6H17.2a3 3 0 0 1-3-2.6L12 16.1a1.5 1.5 0 0 1 1.5-1.7Z"/></clipPath>
<g clip-path="url(#CLIP)"><g class="gulp__liq"><g class="gulp__lvl"><path class="gulp__fill" d="M-24 0q6-3.2 12 0t12 0 12 0 12 0 12 0 12 0 12 0 12 0V80H-24Z"/></g></g></g>
<path d="M13.5 14.4H32.5a1.5 1.5 0 0 1 1.5 1.7L31.8 39.4a3 3 0 0 1-3 2.6H17.2a3 3 0 0 1-3-2.6L12 16.1a1.5 1.5 0 0 1 1.5-1.7Z"/>
<g class="gulp__lid"><path d="M8.1 9.4H38.1"/><path d="M18.6 9.4V9a4.4 4.4 0 0 1 8.8 0v.4"/></g>
</g>
</svg>
<span data-label>Delete</span>
</button>
<svg class="gulp__ring" viewBox="0 0 130 130" aria-hidden="true">
<circle class="gulp__seal" cx="65" cy="65" r="53.5" pathLength="100" transform="rotate(-90 65 65)"/>
<g class="gulp__spin"><circle class="gulp__arc" cx="65" cy="65" r="59" pathLength="100"/></g>
</svg>
</span>
<span class="gulp__focus"></span><span class="sr" role="status" aria-live="polite"></span>
</template>
<script>
(() => {
const fit = () => document.getElementById('scene').style.setProperty('--k', Math.min(innerWidth / 720, innerHeight / 660, 1.3));
fit(); addEventListener('resize', fit);
const sleep = ms => new Promise(r => setTimeout(r, ms));
const reduce = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
const S = { sound: true, eaten: 0 };
const cnt = document.getElementById('cnt');
/* sound: pitch climbs a pentatonic scale with the fill level (starts only after a click) */
let ac;
const tone = (f, d = .14, type = 'triangle', g = .09, at = 0) => {
if (!S.sound) return;
try {
ac = ac || new (window.AudioContext || window.webkitAudioContext)();
if (ac.state === 'suspended') ac.resume();
const o = ac.createOscillator(), v = ac.createGain(), t = ac.currentTime + at;
o.type = type; o.frequency.value = f;
v.gain.setValueAtTime(g, t); v.gain.exponentialRampToValueAtTime(.0001, t + d);
o.connect(v).connect(ac.destination); o.start(t); o.stop(t + d + .02);
} catch (e) {}
};
const PENT = [0, 2, 4, 7, 9, 12, 14, 16];
const note = n => 261.6 * Math.pow(2, PENT[n % PENT.length] / 12);
const buzz = p => { try { navigator.vibrate && navigator.vibrate(p); } catch (e) {} };
const eat = n => { S.eaten += n; cnt.textContent = S.eaten + (S.eaten === 1 ? ' letter eaten' : ' letters eaten'); };
const SPIN = [-55, 42, -70, 62, -46, 78];
let uid = 0;
function Gulp(root, { fail = false } = {}) {
root.innerHTML = document.getElementById('tpl').innerHTML.replace(/CLIP/g, 'gc' + (++uid));
const $ = s => root.querySelector(s);
const btn = $('.gulp__btn'), label = $('[data-label]'), can = $('.gulp__can'), liq = $('.gulp__liq'), live = $('.sr'), body = $('.gulp__body');
const word = label.textContent.trim();
label.textContent = '';
const chars = [...word].map(c => {
const s = document.createElement('span');
s.className = 'gulp__ch'; s.textContent = c; s.setAttribute('aria-hidden', 'true');
label.appendChild(s); return s;
});
const set = s => { root.dataset.state = s; };
const setP = v => root.style.setProperty('--p', v);
const hit = el => { el.removeAttribute('data-hit'); void el.getBoundingClientRect(); el.setAttribute('data-hit', ''); };
/* magnetic tilt toward the pointer */
root.addEventListener('pointermove', e => {
if (root.dataset.state !== 'idle') return;
const r = root.getBoundingClientRect(), k = r.width / 240;
const x = (e.clientX - r.left - r.width / 2) / k, y = (e.clientY - r.top - r.height / 2) / k;
body.style.setProperty('--mx', (x * .07).toFixed(1) + 'px');
body.style.setProperty('--my', (y * .1).toFixed(1) + 'px');
body.style.setProperty('--rot', (x * .012).toFixed(2) + 'deg');
});
root.addEventListener('pointerleave', () => ['--mx', '--my', '--rot'].forEach(p => body.style.removeProperty(p)));
const spark = () => {
for (let i = 0; i < 18; i++) {
const s = document.createElement('i'); s.className = 'spark'; root.appendChild(s);
const a = (i / 18) * Math.PI * 2, d = 70 + (i % 3) * 24;
s.animate([{ transform: 'translate(0,0) rotate(0) scale(1)', opacity: 1 },
{ transform: `translate(${Math.cos(a) * d}px,${Math.sin(a) * d}px) rotate(200deg) scale(0)`, opacity: 0 }],
{ duration: 650 + (i % 4) * 80, easing: 'cubic-bezier(.16,1,.3,1)' }).onfinish = () => s.remove();
}
};
const fly = () => {
const bx = 28 + 23, by = 25 + 12;
return chars.map((ch, i) => {
const cx = label.offsetLeft + ch.offsetLeft + ch.offsetWidth / 2, cy = label.offsetTop + ch.offsetTop + ch.offsetHeight / 2;
const dx = bx + ((i * 37) % 7) - 3 - cx, dy = by + ((i * 53) % 5) - 2 - cy, r = SPIN[i % 6];
return ch.animate([
{ transform: 'translate(0,0) rotate(0) scale(1)', opacity: 1, offset: 0, easing: 'cubic-bezier(.3,0,.4,1)' },
{ transform: `translate(0,-6px) rotate(${r * -.3}deg) scale(1.12)`, offset: .1, easing: 'cubic-bezier(.2,.6,.3,1)' },
{ transform: `translate(${dx * .5}px,${dy * .1 - 32}px) rotate(${r * .6}deg) scale(.85)`, offset: .45, easing: 'cubic-bezier(.45,0,.8,.45)' },
{ transform: `translate(${dx}px,${dy}px) rotate(${r}deg) scale(.4)`, opacity: 1, offset: .9 },
{ transform: `translate(${dx}px,${dy}px) rotate(${r}deg) scale(.3)`, opacity: 0, offset: 1 }
], { duration: 580, delay: 200 + i * 120, fill: 'both' });
});
};
let busy = false;
btn.addEventListener('click', async () => {
if (busy) return; busy = true;
root.setAttribute('aria-busy', 'true'); live.textContent = 'Deleting…';
const quick = reduce();
try {
setP(0); set('eat'); tone(180, .12, 'sine', .08);
const anims = quick ? [] : fly();
let landed = 0;
const done = anims.map(a => a.finished.then(() => {
landed++; setP(landed / chars.length); hit(liq); hit(can);
tone(note(landed - 1), .16); buzz(8); eat(1);
}));
await Promise.all(quick ? [sleep(60)] : done);
set('furl'); await sleep(quick ? 40 : 460);
set('pending'); tone(520, .08, 'sine', .05);
await sleep(quick ? 150 : 1100);
if (fail) {
/* error = the timeline rewinds: the bin spits the letters back, one by one */
set('back'); root.dataset.shake = ''; buzz([40, 40, 60]);
tone(150, .25, 'sawtooth', .07); tone(110, .3, 'sawtooth', .07, .12);
live.textContent = 'Could not delete';
for (let i = chars.length - 1; i >= 0; i--) {
const a = anims[i];
if (a) { a.pause(); a.currentTime = a.effect.getComputedTiming().endTime; a.playbackRate = -2.4; a.play(); }
setP(i / chars.length); hit(liq); tone(note(i) * .5, .1, 'square', .04); eat(-1);
await sleep(95);
}
await sleep(600);
delete root.dataset.shake;
} else {
set('done'); setP(0); spark(); buzz([30, 30, 50]);
tone(note(5), .5, 'sine', .08); tone(note(7), .6, 'sine', .07, .09);
live.textContent = 'Deleted';
await sleep(quick ? 300 : 650);
set('back'); await sleep(quick ? 80 : 620);
}
} finally {
chars.forEach(c => c.getAnimations().forEach(a => a.cancel()));
set('idle'); setP(0); busy = false; root.removeAttribute('aria-busy');
}
});
}
document.querySelectorAll('.gulp').forEach(el => Gulp(el, { fail: !!el.dataset.fail }));
document.getElementById('snd').onclick = e => {
S.sound = !S.sound; e.target.setAttribute('aria-pressed', S.sound); e.target.textContent = S.sound ? 'Sound on' : 'Sound off';
};
})();
</script>
</body>
</html>