03落下値反発定着 — 位置の値が下に落ちて床の値ではね返ることを繰り返し止まる方式
位置の値が下に落ち、床となる値ではね返る動きを繰り返し、その高さの値がだんだん小さくなって止まります。
使いどころ画面表示時に現れる強調ボタン、通知バッジが付いたアクションボタン
GRAVITYRESTITUTIONSTOP_VEL

<main class="stage">
<div class="bgd is-demo" id="bgd">
<div class="bgd__field" id="bgdField">
<button class="bgd__chip" id="bgdChip" type="button">通知 3</button>
<span class="bgd__floor" aria-hidden="true"></span>
</div>
<button class="bgd__drop" id="bgdDrop" type="button">もう一度落とす</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(중력 낙하·반동 적분)은 JS 에 있다 — 여기는 정적 배치만.
@include stage($stage-yellow);
.stage { position: relative; }
.bgd {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
.bgd__field {
position: relative;
width: 200px;
height: 176px;
overflow: hidden;
}
.bgd__chip {
position: absolute;
left: 50%;
top: 0;
margin-left: -46px;
width: 92px;
height: 34px;
border: 0;
border-radius: $r-pill;
background: $stage-orange;
color: $subject-ink;
font-size: 13px;
font-weight: 700;
cursor: pointer;
box-shadow: $shadow-press;
}
.bgd__floor {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
background: rgba($subject-ink, .28);
}
.bgd__drop {
border: 0;
border-radius: $r-control;
padding: $sp-2 $sp-5;
background: $stage-paper;
color: $subject-ink;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
@media (max-width: 340px) {
.bgd__field { width: 150px; height: 108px; }
}
@media (prefers-reduced-motion: reduce) {
.bgd__chip { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.bgd {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.bgd__field {
position: relative;
width: 200px;
height: 176px;
overflow: hidden;
}
.bgd__chip {
position: absolute;
left: 50%;
top: 0;
margin-left: -46px;
width: 92px;
height: 34px;
border: 0;
border-radius: 999px;
background: #ff4d1f;
color: #17141a;
font-size: 13px;
font-weight: 700;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.bgd__floor {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
background: rgba(23, 20, 26, 0.28);
}
.bgd__drop {
border: 0;
border-radius: 12px;
padding: 8px 20px;
background: #ffffff;
color: #17141a;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
@media (max-width: 340px) {
.bgd__field {
width: 150px;
height: 108px;
}
}
@media (prefers-reduced-motion: reduce) {
.bgd__chip {
transition: none !important;
}
}(function () {
var root = document.getElementById('bgd');
var field = document.getElementById('bgdField');
var chip = document.getElementById('bgdChip');
var dropBtn = document.getElementById('bgdDrop');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 계산 겹: vel += GRAVITY*dt, y += vel*dt 로 낙하시키다 바닥 값(FLOOR)에 닿으면
// vel 을 -RESTITUTION 배로 뒤집어 반동시킨다. |반동 속도| 가 STOP_VEL 아래로 줄면 정착.
var GRAVITY = 2200, RESTITUTION = 0.52, STOP_VEL = 40, CHIP_H = 34;
var y = 0, vel = 0, last = 0, raf = null, running = false;
function floorY() { return field.clientHeight - CHIP_H; }
function paint() { chip.style.transform = 'translateY(' + y.toFixed(1) + 'px)'; }
function tick(now) {
var dt = Math.min((now - last) / 1000, 0.032);
last = now;
vel += GRAVITY * dt;
y += vel * dt;
var floor = floorY();
if (y >= floor) {
y = floor;
vel = -vel * RESTITUTION;
if (Math.abs(vel) < STOP_VEL) { vel = 0; paint(); running = false; return; }
}
paint();
raf = requestAnimationFrame(tick);
}
function drop() {
if (reduce) { y = floorY(); vel = 0; paint(); return; }
y = 0; vel = 0; last = performance.now();
if (!running) { running = true; raf = requestAnimationFrame(tick); }
}
function wireReal() {
dropBtn.addEventListener('click', function () { root.classList.remove('is-demo'); stopAutoplay(); drop(); });
chip.addEventListener('click', function () { root.classList.remove('is-demo'); stopAutoplay(); drop(); });
}
wireReal();
y = floorY(); paint();
// 자동 루프: 같은 drop() 을 주기적으로 다시 불러 낙하-반동 경로를 반복 재현한다.
var autoTimer = null;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { drop(); autoTimer = setInterval(drop, 1600); }
})();import { useEffect, useRef } from "react";
import styles from "./ButtonPhysicsGravityDropSettle.module.scss";
export type ButtonPhysicsGravityDropSettleProps = {
color?: string;
};
export default function ButtonPhysicsGravityDropSettle({
color = "#ff4d1f",
}: ButtonPhysicsGravityDropSettleProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const field = root.querySelector<HTMLDivElement>(`.${styles.field}`);
const chip = root.querySelector<HTMLButtonElement>(`.${styles.chip}`);
const dropBtn = root.querySelector<HTMLButtonElement>(`.${styles.drop}`);
if (!field || !chip || !dropBtn) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 계산 겹: vel += GRAVITY*dt, y += vel*dt 로 낙하시키다 바닥 값(FLOOR)에 닿으면
// vel 을 -RESTITUTION 배로 뒤집어 반동시킨다. |반동 속도| 가 STOP_VEL 아래로 줄면 정착.
const GRAVITY = 2200, RESTITUTION = 0.52, STOP_VEL = 40, CHIP_H = 34;
let y = 0, vel = 0, last = 0, raf: number | null = null, running = false;
function floorY() { return field!.clientHeight - CHIP_H; }
function paint() { chip!.style.transform = "translateY(" + y.toFixed(1) + "px)"; }
function tick(now: number) {
const dt = Math.min((now - last) / 1000, 0.032);
last = now;
vel += GRAVITY * dt;
y += vel * dt;
const floor = floorY();
if (y >= floor) {
y = floor;
vel = -vel * RESTITUTION;
if (Math.abs(vel) < STOP_VEL) { vel = 0; paint(); running = false; return; }
}
paint();
raf = requestAnimationFrame(tick);
}
function drop() {
if (reduce) { y = floorY(); vel = 0; paint(); return; }
y = 0; vel = 0; last = performance.now();
if (!running) { running = true; raf = requestAnimationFrame(tick); }
}
function onDropClick() { stopAutoplay(); drop(); }
function onChipClick() { stopAutoplay(); drop(); }
dropBtn.addEventListener("click", onDropClick);
chip.addEventListener("click", onChipClick);
y = floorY(); paint();
let autoTimer: number | null = null;
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { drop(); autoTimer = window.setInterval(drop, 1600); }
return () => {
stopAutoplay();
if (raf !== null) cancelAnimationFrame(raf);
dropBtn.removeEventListener("click", onDropClick);
chip.removeEventListener("click", onChipClick);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.bgd}>
<div className={styles.field}>
<button className={styles.chip} type="button" style={{ background: color }}>알림 3</button>
<span className={styles.floor} aria-hidden="true" />
</div>
<button className={styles.drop} type="button">다시 떨어뜨리기</button>
</div>
</div>
);
}03 落下値反発定着 — 位置の値が下に落ちて床の値ではね返ることを繰り返し止まる方式 — 計測 累積面積 11.576% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · ボタン 押し込み UI 9選 — 押し込みの値が戻る計算の9通り
このデモが属する記事
ボタン 押し込み UI 9選 — 押し込みの値が戻る計算の9通り9つのうち1つは押し込みの値が目標値を超えてから戻ることを繰り返して落ち着き、1つは落下した位置の値が床の値ではね返ることを繰り返して止まり、もう1つは離した位置の値が最も近い固定地点の値に引き寄せられて弾むように収まります。ボタン 押し込 この記事のデモをすべて見る


