Haptic Feedback

햅틱 피드백

A short physical pulse sized to the tap — light, medium, heavy. The web can't reproduce real vibration, so this demo stands in with a ripple and a waveform instead.

Also known as: Taptic feedback진동 피드백
···
html
<div class="stage"><div class="phone"><div class="notch"></div><div class="screen" id="screen">
<div class="hpwrap">
  <div class="wave"><span></span><span></span><span></span><span></span><span></span></div>
  <div class="hprows">
    <div class="hprow"><span>Light</span><span class="hpdot"></span></div>
    <div class="hprow"><span>Medium</span><span class="hpdot"></span></div>
    <div class="hprow"><span>Heavy</span><span class="hpdot"></span></div>
  </div>
</div></div><div class="home"></div></div></div>
css
.stage{position:absolute;inset:0;display:grid;place-items:center;overflow:hidden}
.phone{position:relative;height:90vmin;max-height:640px;aspect-ratio:9/19.5;background:#08080c;
  border-radius:20%/8.6%;box-shadow:0 0 0 2px rgba(255,255,255,.08) inset,0 12px 30px rgba(0,0,0,.35)}
.phone .notch{position:absolute;left:50%;top:0;transform:translateX(-50%);width:38%;height:5.6%;
  background:#08080c;border-radius:0 0 46% 46%;z-index:80}
.phone .screen{position:absolute;top:7.6%;left:3%;right:3%;bottom:3%;border-radius:11%/4.6%;overflow:hidden;
  background:var(--surface);color:var(--fg)}
.phone .home{position:absolute;left:50%;bottom:1.1%;transform:translateX(-50%);width:32%;height:3px;
  border-radius:2px;background:rgba(255,255,255,.45);z-index:80}
.pfx{position:absolute;left:0;top:0;width:16px;height:16px;margin:-8px 0 0 -8px;border-radius:50%;
  background:color-mix(in srgb,var(--accent) 55%,transparent);
  box-shadow:0 0 0 6px color-mix(in srgb,var(--accent) 15%,transparent);pointer-events:none;z-index:999;
  transition:opacity .2s}
.pfx.hold{width:30px;height:30px;margin:-15px 0 0 -15px;
  box-shadow:0 0 0 9px color-mix(in srgb,var(--accent) 20%,transparent);
  transition:width .5s linear,height .5s linear,margin .5s linear,box-shadow .5s linear,opacity .2s}

.hpwrap{position:absolute;inset:0;padding:8% 8% 0;display:grid;grid-template-rows:auto 1fr}
.wave{justify-self:center;display:flex;align-items:center;gap:5%;height:clamp(16px,10vmin,26px);margin-bottom:8%}
.wave span{width:clamp(2.5px,1.4vmin,4px);height:22%;border-radius:2px;background:var(--accent);transition:height .18s ease}
.hprows{display:grid;gap:6%;align-content:start}
.hprow{position:relative;overflow:hidden;display:flex;align-items:center;justify-content:space-between;
  padding:5% 6%;border:1px solid var(--line);border-radius:12px;background:var(--surface)}
.hprow span:first-child{font-size:clamp(6.5px,2.3vmin,9px);font-weight:700;color:var(--fg)}
.hpdot{width:clamp(8px,4vmin,12px);height:clamp(8px,4vmin,12px);border-radius:50%;background:var(--accent);
  opacity:.3;transition:opacity .15s,transform .15s;flex:none}
.hprow.fire .hpdot{opacity:1;transform:scale(1.3)}
.ripple{position:absolute;border-radius:50%;background:color-mix(in srgb,var(--accent) 40%,transparent);
  transform:scale(0);opacity:.8;pointer-events:none}
@keyframes rip{to{transform:scale(1);opacity:0}}
js
const pfx=document.createElement('div');pfx.className='pfx';document.body.appendChild(pfx);
const screenEl=document.getElementById('screen');
let pfxAuto=true;
function pfxHide(){pfx.style.opacity='0';}
addEventListener('pointerdown',()=>{pfxAuto=false;pfxHide();},{capture:true});
function pfxAt(fx,fy){
  const r=screenEl.getBoundingClientRect();
  const x=r.left+r.width*fx, y=r.top+r.height*fy;
  pfx.style.transform='translate('+x+'px,'+y+'px)';
  return {x,y};
}
function pfxWalk(x0,y0,x1,y1,ms){
  return new Promise(res=>{
    const start=performance.now();
    function step(now){
      if(!pfxAuto) return res();
      const p=Math.min(1,(now-start)/ms), e=1-Math.pow(1-p,3);
      pfxAt(x0+(x1-x0)*e, y0+(y1-y0)*e);
      if(p<1) requestAnimationFrame(step); else res();
    }
    requestAnimationFrame(step);
  });
}
function pfxWait(ms){
  return new Promise(res=>{
    const start=performance.now();
    function step(now){ if(!pfxAuto) return res(); if(now-start<ms) requestAnimationFrame(step); else res(); }
    requestAnimationFrame(step);
  });
}

const rows=[...document.querySelectorAll('.hprow')], waves=[...document.querySelectorAll('.wave span')];
const levels=[0.3,0.6,1];
function fire(i){
  const level=levels[i], row=rows[i], dot=row.querySelector('.hpdot');
  row.classList.add('fire'); setTimeout(()=>row.classList.remove('fire'),220);
  const r=row.getBoundingClientRect(), dr=dot.getBoundingClientRect();
  const rip=document.createElement('span'); rip.className='ripple';
  const size=Math.max(r.width,r.height)*1.2;
  rip.style.width=rip.style.height=size+'px';
  rip.style.left=(dr.left-r.left+dr.width/2-size/2)+'px'; rip.style.top=(dr.top-r.top+dr.height/2-size/2)+'px';
  rip.style.animation='rip '+(0.35+level*0.25)+'s ease-out forwards';
  row.appendChild(rip); setTimeout(()=>rip.remove(),700);
  waves.forEach((w,j)=>{
    const h=(20+Math.random()*60)*level+18;
    setTimeout(()=>{ w.style.height=h+'%'; setTimeout(()=>{ w.style.height='22%'; },140); },j*22);
  });
}
rows.forEach((row,i)=>row.addEventListener('pointerdown',()=>{ pfxAuto=false; pfxHide(); fire(i); }));
async function autoSeq(){
  let i=0;
  while(pfxAuto){
    const row=rows[i], dr=row.querySelector('.hpdot').getBoundingClientRect(), sr=screenEl.getBoundingClientRect();
    const fx=(dr.left+dr.width/2-sr.left)/sr.width, fy=(dr.top+dr.height/2-sr.top)/sr.height;
    await pfxWalk(fx,fy,fx,fy,380); if(!pfxAuto)break;
    fire(i);
    await pfxWait(750);
    i=(i+1)%rows.length;
  }
}
autoSeq();

iOS fires the Taptic Engine through `UIImpactFeedbackGenerator`'s `.light/.medium/.heavy/.rigid/.soft` styles, plus `UINotificationFeedbackGenerator` (success/warning/error) for outcomes; Android does the same job with `VibrationEffect`'s predefined effects. The point is timing the physical pulse to the exact same frame as the UI event — a toggle flipping, an item being picked up to reorder, an error appearing — so a finger can confirm the action actually landed without eyes ever leaving what they were doing.

The web's `navigator.vibrate()` isn't supported on iOS Safari at all, only fires inside a user gesture, and can't reproduce the Taptic Engine's nuanced intensity curves. So demonstrating the concept honestly on the web means substituting something visual — a ripple sized to the tap, and a waveform whose bars jump in proportion to the intensity.

Light, medium, and heavy exist as distinct tiers because touch can carry information too — a light pulse for a casual choice, a heavier one for an action that can't be undone lets a fingertip tell the weight of what just happened without looking.

When to use

Use it for immediate confirmation that a state actually changed — a toggle, picking something up to reorder, success or error. Firing it on every single tap dulls the signal instead of reinforcing it.