리플 이펙트

Ripple Effect

클릭한 지점에서 원이 퍼져나가며 사라지는 효과. 머티리얼 디자인에서 "여기를 눌렀다"는 즉각적인 피드백으로 씁니다.

다른 이름: Material rippleInk effect
···
html
<button class="rip" id="ripbtn">Tap</button>
css
.fx-dot{position:absolute;left:0;top:0;width:12px;height:12px;margin:-6px 0 0 -6px;border-radius:50%;
  background:var(--accent);box-shadow:0 0 0 5px color-mix(in srgb,var(--accent) 18%,transparent),0 2px 6px rgba(0,0,0,.3);
  pointer-events:none;z-index:9999;transition:opacity .25s}
.rip{position:relative;overflow:hidden;padding:18px 34px;border:none;border-radius:10px;background:var(--accent);
  color:#fff;font-weight:700;font-size:clamp(12px,3.8vmin,15px);cursor:pointer}
.ripple{position:absolute;border-radius:50%;background:rgba(255,255,255,.55);transform:scale(0);
  animation:rippleAnim .7s ease-out forwards;pointer-events:none}
@keyframes rippleAnim{to{transform:scale(1);opacity:0}}
js
const fxDot=document.createElement('div');fxDot.className='fx-dot';document.body.appendChild(fxDot);
let fxAuto=true,fxX=innerWidth/2,fxY=innerHeight/2;
function fxHide(){fxDot.style.opacity='0';}
addEventListener('pointerdown',()=>{fxAuto=false;fxHide();},{capture:true});
addEventListener('pointermove',e=>{fxAuto=false;fxHide();fxX=e.clientX;fxY=e.clientY;},{capture:true});
function fxWalk(x,y,ms){
  return new Promise(res=>{
    const sx=fxX,sy=fxY,start=performance.now();
    function step(now){
      if(!fxAuto)return res();
      const p=Math.min(1,(now-start)/ms),e=1-Math.pow(1-p,3);
      fxX=sx+(x-sx)*e;fxY=sy+(y-sy)*e;
      fxDot.style.transform='translate('+fxX+'px,'+fxY+'px)';
      if(p<1)requestAnimationFrame(step);else res();
    }
    requestAnimationFrame(step);
  });
}
function fxWait(ms){
  return new Promise(res=>{
    const start=performance.now();
    function step(now){ if(!fxAuto)return res(); if(now-start<ms)requestAnimationFrame(step);else res(); }
    requestAnimationFrame(step);
  });
}

const btn=document.getElementById('ripbtn');
function makeRipple(x,y){
  const r=btn.getBoundingClientRect();
  const size=Math.max(r.width,r.height)*2;
  const s=document.createElement('span');
  s.className='ripple';
  s.style.width=s.style.height=size+'px';
  s.style.left=(x-r.left-size/2)+'px';
  s.style.top=(y-r.top-size/2)+'px';
  btn.appendChild(s);
  s.addEventListener('animationend',()=>s.remove());
}
btn.addEventListener('pointerdown',e=>{ fxAuto=false; fxHide(); makeRipple(e.clientX,e.clientY); });
async function autoSeq(){
  while(fxAuto){
    const r=btn.getBoundingClientRect();
    const tx=r.left+r.width*(0.3+Math.random()*0.4);
    const ty=r.top+r.height/2;
    await fxWalk(tx,ty,500); if(!fxAuto) break;
    makeRipple(tx,ty);
    await fxWait(1000);
  }
}
autoSeq();

클릭 좌표를 기준으로, 버튼을 모두 덮을 만큼 큰 지름(`Math.max(width,height) * 2`)의 원을 만들어 클릭 지점에 중심을 맞춰 배치한다. 버튼에는 `overflow: hidden`을 걸어 원이 버튼 밖으로 삐져나가지 않게 자른다.

`transform: scale(0)`에서 `scale(1)`로 커지면서 동시에 `opacity`가 줄어드는 애니메이션을 `@keyframes`로 걸고, `animationend`에서 요소를 제거한다 — DOM에 계속 쌓이지 않도록 정리하는 것이 중요하다.

버튼 배경이 진하면 흰색 반투명(`rgba(255,255,255,.5)`) 원이, 배경이 밝으면 어두운 반투명 원이 더 잘 보인다. 클릭할 때마다 새 원을 추가해서 겹쳐 눌러도 자연스럽게 겹쳐 보이게 한다.

언제 쓰나

주요 버튼, 리스트 항목 탭처럼 "눌렸다"는 즉각적 확인이 필요한 곳에 씁니다. 이미 hover 상태 변화가 뚜렷한 버튼에는 과할 수 있습니다.