06호버 모핑 — 대기 상태와 호버 상태의 모서리 곡선 값이 보간되며 넘어가는 방식
호버가 바뀔 때마다 두 곡선 값 사이를 보간 값이 매끈히 이어, 둥근 모양에서 베벨 모양으로 끊김 없이 넘어갑니다.
쓰임네비게이션 항목, 필터 버튼
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 원작 · zip 에 SCSS·React 포함 · css corner-shape 9종 — border-radius 없이 깎는 모서리 모양
이 데모가 속한 글
css corner-shape 9종 — border-radius 없이 깎는 모서리 모양아홉 가지 모서리 모양 중 하나는 슈퍼타원 지수 값이 원과 사각형 사이를 흐르며 카드 능선이 바뀌고, 하나는 스쿠어클 비율 값에 따라 모서리가 보간되고, 하나는 베벨 각도 값이 커질수록 빗면 넓이 값이 함께 커집니다 이 글의 데모 전부 보기


