05Pressure ripple — as a press-pressure value rises, a concentric ripple's radius and amplitude values grow together
At a low pressure value the ripple spreads small and shallow, and as the pressure value rises the radius and amplitude values grow proportionally, making the ripple stand out.
Best forA fingerprint-sensor interaction demo, a touch-pressure feedback background
PRESS_CYCLE_MS$subject-blue-lightWAVE_K

<main class="stage">
<div class="ripple-wrap">
<button type="button" class="ripple is-demo" id="ripple" aria-pressed="false">
<svg class="ripple__icon" viewBox="0 0 120 120" aria-hidden="true">
<path class="ripple__wave" id="wave"></path>
<circle class="ripple__core" cx="60" cy="60" r="10"></circle>
</svg>
<span class="ripple__label">Press to authenticate</span>
</button>
<p class="ripple__status" id="status">Pressure 0%</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 buildPath() 에 있다 — 각도마다 반지름을 사인 함수로 계산해(물결 진폭)
// 매 프레임 점을 다시 찍는다. 진폭은 포인터 압력(pointerPressure) 에 비례한다.
@include stage($stage-paper);
.ripple-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.ripple {
appearance: none;
border: none;
background: transparent;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
cursor: pointer;
padding: $sp-4;
border-radius: $r-card;
touch-action: none;
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 4px; }
}
.ripple__icon {
width: clamp(120px, 30vw, 160px);
height: auto;
overflow: visible;
}
.ripple__wave {
fill: none;
stroke: $subject-blue-light;
stroke-width: 2.5;
}
.ripple__core {
fill: $subject-blue-deep;
}
.ripple__label {
color: $stage-ink;
font-weight: 600;
font-size: 13px;
line-height: 1.2;
letter-spacing: .02em;
}
.ripple__status {
margin: 0;
color: rgba($stage-ink, .58);
font-weight: 500;
font-size: 12px;
line-height: 1.2;
}
@media (max-width: 340px) {
.stage { padding: $sp-2; }
.ripple-wrap { gap: $sp-2; }
.ripple { padding: $sp-2 $sp-3; gap: $sp-2; }
.ripple__icon { width: 104px; }
.ripple__label { font-size: 12px; }
.ripple__status { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.ripple__wave { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ripple-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.ripple {
appearance: none;
border: none;
background: transparent;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
cursor: pointer;
padding: 16px;
border-radius: 16px;
touch-action: none;
}
.ripple:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 4px;
}
.ripple__icon {
width: clamp(120px, 30vw, 160px);
height: auto;
overflow: visible;
}
.ripple__wave {
fill: none;
stroke: #5b8cf8;
stroke-width: 2.5;
}
.ripple__core {
fill: #0a4fe8;
}
.ripple__label {
color: #17141a;
font-weight: 600;
font-size: 13px;
line-height: 1.2;
letter-spacing: 0.02em;
}
.ripple__status {
margin: 0;
color: rgba(23, 20, 26, 0.58);
font-weight: 500;
font-size: 12px;
line-height: 1.2;
}
@media (max-width: 340px) {
.stage {
padding: 8px;
}
.ripple-wrap {
gap: 8px;
}
.ripple {
padding: 8px 12px;
gap: 8px;
}
.ripple__icon {
width: 104px;
}
.ripple__label {
font-size: 12px;
}
.ripple__status {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.ripple__wave {
transition: none;
}
}(function () {
// 계산 겹: 물결 진폭 — 각도(θ)마다 반지름을 BASE_R + AMPLITUDE*pressure*sin(WAVE_K*θ - t*WAVE_SPEED)
// 로 계산해 M 개 점을 매 프레임 다시 찍는다. pressure 는 실 포인터 압력(e.pressure) 또는
// 자동 루프의 누르기-떼기 사인 곡선에서 온다 — 눌린 힘이 셀수록 물결의 진폭이 커진다.
var BASE_R = 30;
var AMPLITUDE = 11; // 최대 압력일 때 물결이 흔들리는 폭
var WAVE_K = 5; // 물결 마루 개수
var WAVE_SPEED = 0.0032; // 물결이 도는 각속도(rad/ms)
var M = 32; // 원둘레를 따라 찍는 점 개수
var PRESS_CYCLE_MS = 1400; // 자동 루프에서 누르기→떼기 한 번의 시간
var GAP_MS = 500;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('ripple');
var wave = document.getElementById('wave');
var status = document.getElementById('status');
var isDemo = true;
var pointerPressure = 0;
var pointerDown = false;
var autoStart = 0;
function buildPath(t, pressure) {
var d = '';
for (var i = 0; i <= M; i++) {
var theta = (i / M) * Math.PI * 2;
var r = BASE_R + AMPLITUDE * pressure * Math.sin(WAVE_K * theta - t * WAVE_SPEED);
var x = 60 + r * Math.cos(theta);
var y = 60 + r * Math.sin(theta);
d += (i === 0 ? 'M' : 'L') + x.toFixed(2) + ',' + y.toFixed(2) + ' ';
}
return d + 'Z';
}
function autoPressure(now) {
if (!autoStart) autoStart = now;
var phase = (now - autoStart) % (PRESS_CYCLE_MS + GAP_MS);
if (phase > PRESS_CYCLE_MS) return 0;
return Math.max(0, Math.sin((phase / PRESS_CYCLE_MS) * Math.PI));
}
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
root.addEventListener('pointerdown', function (e) {
human();
pointerDown = true;
pointerPressure = e.pressure > 0 ? e.pressure : 0.6;
root.setAttribute('aria-pressed', 'true');
});
root.addEventListener('pointermove', function (e) {
if (!pointerDown) return;
pointerPressure = e.pressure > 0 ? e.pressure : pointerPressure;
});
window.addEventListener('pointerup', function () {
if (!pointerDown) return;
pointerDown = false;
pointerPressure = 0;
root.setAttribute('aria-pressed', 'false');
});
root.addEventListener('keydown', function (e) {
if (e.key === ' ' || e.key === 'Enter') { human(); }
});
function loop(ts) {
var reduceNow = reduce && isDemo;
var pressure = pointerDown ? pointerPressure : (isDemo && !reduce ? autoPressure(ts) : 0);
if (reduceNow) { pressure = 0; }
wave.setAttribute('d', buildPath(ts, pressure));
status.textContent = 'Pressure ' + Math.round(pressure * 100) + '%';
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// PressureRipple — vanilla 판(run/371/demo/05-biometric-scan-pressure-ripple/index.html) 의 React 포트.
// 계산 겹(물결 진폭)은 usePressureRipple 훅 안에 있다: 각도(θ)마다 반지름을
// BASE_R + AMPLITUDE*pressure*sin(WAVE_K*θ - t*WAVE_SPEED) 로 계산해 매 프레임 점을 다시 찍는다.
// pressure 는 실 포인터 압력(e.pressure) 또는 자동 루프의 누르기-떼기 사인 곡선에서 온다.
import { useEffect, useRef, useState } from "react";
import styles from "./PressureRipple.module.scss";
type Box<T> = { current: T };
function usePressureRipple(
rootRef: Box<HTMLButtonElement>,
waveRef: Box<SVGPathElement>,
setPressure: (p: number) => void,
setPressed: (b: boolean) => void
) {
useEffect(() => {
const BASE_R = 30;
const AMPLITUDE = 11;
const WAVE_K = 5;
const WAVE_SPEED = 0.0032;
const M = 32;
const PRESS_CYCLE_MS = 1400;
const GAP_MS = 500;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const root = rootRef.current;
const wave = waveRef.current;
if (!root || !wave) return;
let isDemo = true;
let pointerPressure = 0;
let pointerDown = false;
let autoStart = 0;
let rafId = 0;
function buildPath(t: number, pressure: number) {
let d = "";
for (let i = 0; i <= M; i++) {
const theta = (i / M) * Math.PI * 2;
const r = BASE_R + AMPLITUDE * pressure * Math.sin(WAVE_K * theta - t * WAVE_SPEED);
const x = 60 + r * Math.cos(theta);
const y = 60 + r * Math.sin(theta);
d += (i === 0 ? "M" : "L") + x.toFixed(2) + "," + y.toFixed(2) + " ";
}
return d + "Z";
}
function autoPressure(now: number) {
if (!autoStart) autoStart = now;
const phase = (now - autoStart) % (PRESS_CYCLE_MS + GAP_MS);
if (phase > PRESS_CYCLE_MS) return 0;
return Math.max(0, Math.sin((phase / PRESS_CYCLE_MS) * Math.PI));
}
function human() { if (isDemo) { root.classList.remove(styles.isDemo); isDemo = false; } }
function onPointerDown(e: PointerEvent) {
human();
pointerDown = true;
pointerPressure = e.pressure > 0 ? e.pressure : 0.6;
setPressed(true);
}
function onPointerMove(e: PointerEvent) {
if (!pointerDown) return;
pointerPressure = e.pressure > 0 ? e.pressure : pointerPressure;
}
function onPointerUp() {
if (!pointerDown) return;
pointerDown = false;
pointerPressure = 0;
setPressed(false);
}
function onKeyDown(e: KeyboardEvent) { if (e.key === " " || e.key === "Enter") human(); }
root.addEventListener("pointerdown", onPointerDown);
root.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
root.addEventListener("keydown", onKeyDown);
function loop(ts: number) {
const reduceNow = reduce && isDemo;
let pressure = pointerDown ? pointerPressure : (isDemo && !reduce ? autoPressure(ts) : 0);
if (reduceNow) pressure = 0;
wave.setAttribute("d", buildPath(ts, pressure));
setPressure(pressure);
rafId = requestAnimationFrame(loop);
}
rafId = requestAnimationFrame(loop);
return () => {
root.removeEventListener("pointerdown", onPointerDown);
root.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
root.removeEventListener("keydown", onKeyDown);
cancelAnimationFrame(rafId);
};
}, []);
}
export default function PressureRipple() {
const rootRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const waveRef = useRef(null) as unknown as Box<SVGPathElement>;
const [pressure, setPressure] = useState(0);
const [pressed, setPressed] = useState(false);
usePressureRipple(rootRef, waveRef, setPressure, setPressed);
return (
<main className={styles.stage}>
<div className={styles.rippleWrap}>
<button
type="button"
className={styles.ripple + " " + styles.isDemo}
ref={rootRef}
aria-pressed={pressed}
>
<svg className={styles.rippleIcon} viewBox="0 0 120 120" aria-hidden="true">
<path className={styles.rippleWave} ref={waveRef} />
<circle className={styles.rippleCore} cx={60} cy={60} r={10} />
</svg>
<span className={styles.rippleLabel}>눌러서 인증</span>
</button>
<p className={styles.rippleStatus}>{"누르는 힘 " + Math.round(pressure * 100) + "%"}</p>
</div>
</main>
);
}05 Pressure ripple — as a press-pressure value rises, a concentric ripple's radius and amplitude values grow together — Measured area 3.583% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · Fingerprint Scan Animation: 9 Biometric UI Demos
The post this demo lives in
Fingerprint Scan Animation: 9 Biometric UI DemosNine fingerprint scan animation patterns: one lights ridges by scan-progress value, one shakes by spring-damping value o See all demos in this post
If you like this effect - similar demos
Auth UI08 Login history holding successes and failures together9 account security settings UI patterns
Auth UI01 Remaining-tries shake — the remaining-tries value drops and the number shakes briefly each time login failsToo Many Failed Login Attempts: 9 Patterns
Auth UI08 A Stepped Check Where Warp Amplitude and Line Count Rise One Step per MissCSS Captcha: 9 Warped Text and Rotate Checks