06ホバーモーフィング — 待機状態とホバー状態の角の曲線の値が補間されて移る方式
ホバーが変わるたびに2つの曲線値の間を補間の値が滑らかにつなぎ、丸い形からベベル形へ切れ目なく移る。
使いどころナビゲーション項目、フィルターボタン
LERP_KROUND_R$subject-blue-deep

<main class="stage">
<div class="wrap is-demo" id="wrap">
<button class="card" id="card" type="button" aria-pressed="false" aria-label="ホバーでカードの形を変える"></button>
<p class="readout" id="readout" aria-live="polite">補間比率 0%</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 에 있다 — 목표 비율로 순간 이동하지 않고 지수 감쇠 lerp 로 따라붙는다.
@include stage($stage-yellow);
.wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
.card {
appearance: none;
border: none;
width: 168px;
height: 108px;
background: $subject-blue-deep;
border-radius: $r-card;
cursor: pointer;
box-shadow: $shadow-raised;
&:hover { box-shadow: $shadow-press; }
&:focus-visible { outline: 2px solid $subject-ink; outline-offset: 3px; }
}
@supports not (corner-shape: bevel) {
.card { border: 1px solid rgba($subject-ink, .2); }
}
.readout {
margin: 0;
color: $subject-ink;
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.card { width: 130px; height: 88px; }
}
@media (prefers-reduced-motion: reduce) {
.card { transition: none; }
}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);
}
.wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.card {
appearance: none;
border: none;
width: 168px;
height: 108px;
background: #0a4fe8;
border-radius: 16px;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.card:hover {
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.card:focus-visible {
outline: 2px solid #17141a;
outline-offset: 3px;
}
@supports not (corner-shape: bevel) {
.card {
border: 1px solid rgba(23, 20, 26, 0.2);
}
}
.readout {
margin: 0;
color: #17141a;
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.card {
width: 130px;
height: 88px;
}
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
}(function () {
var ROUND_R = 48; // 대기 상태(둥근 원형)의 半径 상한(px)
var BEVEL_R = 6; // 호버 상태(각진 베벨)의 半径 하한(px)
var LERP_K = 9; // 목표 비율로 붙는 속도 상수 — 클수록 빨리 따라붙는다
var AUTO_MS = 2000; // 자동 루프에서 대기·호버를 오가는 주기
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var SUPPORTS_ROUND = CSS.supports('corner-shape', 'round');
var SUPPORTS_BEVEL = CSS.supports('corner-shape', 'bevel');
var wrap = document.getElementById('wrap');
var card = document.getElementById('card');
var readout = document.getElementById('readout');
var isDemo = true;
var target = 0;
var ratio = 0;
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
function setTarget(v) { target = v; }
card.addEventListener('pointerenter', function () { human(); setTarget(1); });
card.addEventListener('pointerleave', function () { human(); setTarget(0); });
card.addEventListener('focus', function () { human(); setTarget(1); });
card.addEventListener('blur', function () { human(); setTarget(0); });
card.addEventListener('click', function () { human(); card.setAttribute('aria-pressed', String(target === 1)); });
// 계산 겹: 비율 값을 목표로 순간 이동시키지 않고 지수 감쇠(dt 기반 관성 lerp)로 붙인다 — 두 모양 사이 보간 비율이다
var last = 0;
function stepRatio(ts) {
var dt = last ? (ts - last) / 1000 : 0;
last = ts;
var follow = 1 - Math.exp(-LERP_K * dt);
ratio += (target - ratio) * follow;
if (Math.abs(ratio - target) < 0.001) ratio = target;
}
function applyMorph() {
var r = ROUND_R + (BEVEL_R - ROUND_R) * ratio;
var shape = ratio < 0.5 ? (SUPPORTS_ROUND ? 'round' : null) : (SUPPORTS_BEVEL ? 'bevel' : null);
if (shape) card.style.cornerShape = shape;
card.style.borderRadius = r.toFixed(2) + 'px';
readout.textContent = '補間比率 ' + Math.round(ratio * 100) + '%';
}
var start = 0;
function loop(ts) {
if (!start) start = ts;
if (isDemo) {
if (reduce) {
target = 1; // 계산은 유지, 시간 축소 대신 호버 목표 상태로 즉시 고정
} else {
var t = ((ts - start) % AUTO_MS) / AUTO_MS;
target = t < 0.5 ? 0 : 1;
}
}
if (reduce) {
ratio = target;
} else {
stepRatio(ts);
}
applyMorph();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// MorphHover — vanilla 판(run/372/demo/06-corner-shape-morph-hover/index.html) 의 React 포트.
// 비율 값을 목표로 순간 이동시키지 않고 지수 감쇠(dt 기반 관성 lerp)로 붙인다.
import { useEffect, useRef } from "react";
import styles from "./MorphHover.module.scss";
type Box<T> = { current: T };
const ROUND_R = 48; // 대기 상태(둥근 원형)의 반경 상한(px)
const BEVEL_R = 6; // 호버 상태(각진 베벨)의 반경 하한(px)
const LERP_K = 9; // 목표 비율로 붙는 속도 상수 — 클수록 빨리 따라붙는다
const AUTO_MS = 2000; // 자동 루프에서 대기·호버를 오가는 주기
export default function MorphHover() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const cardRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const readoutRef = useRef(null) as unknown as Box<HTMLParagraphElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SUPPORTS_ROUND = CSS.supports("corner-shape", "round");
const SUPPORTS_BEVEL = CSS.supports("corner-shape", "bevel");
const root = rootRef.current;
const card = cardRef.current;
const readout = readoutRef.current;
if (!root || !card || !readout) return;
let isDemo = true;
let target = 0;
let ratio = 0;
function human() { if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; } }
function setTarget(v: number) { target = v; }
function onEnter() { human(); setTarget(1); }
function onLeave() { human(); setTarget(0); }
function onFocus() { human(); setTarget(1); }
function onBlur() { human(); setTarget(0); }
function onClick() { human(); card!.setAttribute("aria-pressed", String(target === 1)); }
card.addEventListener("pointerenter", onEnter);
card.addEventListener("pointerleave", onLeave);
card.addEventListener("focus", onFocus);
card.addEventListener("blur", onBlur);
card.addEventListener("click", onClick);
// 계산 겹: 비율 값을 목표로 순간 이동시키지 않고 지수 감쇠(dt 기반 관성 lerp)로 붙인다 — 두 모양 사이 보간 비율이다
let last = 0;
function stepRatio(ts: number) {
const dt = last ? (ts - last) / 1000 : 0;
last = ts;
const follow = 1 - Math.exp(-LERP_K * dt);
ratio += (target - ratio) * follow;
if (Math.abs(ratio - target) < 0.001) ratio = target;
}
function applyMorph() {
const r = ROUND_R + (BEVEL_R - ROUND_R) * ratio;
const shape = ratio < 0.5 ? (SUPPORTS_ROUND ? "round" : null) : (SUPPORTS_BEVEL ? "bevel" : null);
if (shape) (card!.style as any).cornerShape = shape;
card!.style.borderRadius = r.toFixed(2) + "px";
readout!.textContent = "보간 비율 " + Math.round(ratio * 100) + "%";
}
let start = 0;
function loop(ts: number) {
if (!start) start = ts;
if (isDemo) {
if (reduce) {
target = 1;
} else {
const t = ((ts - start) % AUTO_MS) / AUTO_MS;
target = t < 0.5 ? 0 : 1;
}
}
if (reduce) {
ratio = target;
} else {
stepRatio(ts);
}
applyMorph();
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
card.removeEventListener("pointerenter", onEnter);
card.removeEventListener("pointerleave", onLeave);
card.removeEventListener("focus", onFocus);
card.removeEventListener("blur", onBlur);
card.removeEventListener("click", onClick);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<button
className={styles.card}
ref={cardRef}
type="button"
aria-pressed="false"
aria-label="올려서 카드 모양 바꾸기"
/>
<p className={styles.readout} ref={readoutRef} aria-live="polite">보간 비율 0%</p>
</div>
</main>
);
}06 ホバーモーフィング — 待機状態とホバー状態の角の曲線の値が補間されて移る方式 — 計測 累積面積 3.251% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · css corner-shape 9選 — border-radiusで切らない角の処理
このデモが属する記事
css corner-shape 9選 — border-radiusで切らない角の処理9つのコーナー形状のうち、1つはスーパー楕円指数の値が円と四角の間を流れ、1つはsquircle比率の値でカードの角が補間され、1つはベベル角度の値が増すほど面の幅が広がります。この9つは、css corner-shape の対応を起動時に この記事のデモをすべて見る


