03区切り線フェード — ボタン間の区切り線の透明度の値がポインターが隣のボタンに近づくほど薄くなる方式
ポインター距離の値が区切り線に近づくほど、その区切り線の透明度の値が下がり、隣のボタンとの境が薄くなります。
使いどころ設定パネルの表示切替ボタン群
FALLOFF_PX 46smoothsteplerp SMOOTH .22

<main class="stage">
<div class="dfh is-demo" id="dfh" role="group" aria-label="表示切替">
<button class="dfh__btn">グリッド</button>
<span class="dfh__seam" id="seam0" aria-hidden="true"></span>
<button class="dfh__btn">リスト</button>
<span class="dfh__seam" id="seam1" aria-hidden="true"></span>
<button class="dfh__btn">カード</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html 이 포인터(x) 와 각 구분선의 실측 중점 사이 거리 값을 매 프레임 재고,
// smoothstep falloff 로 목표 투명도를 구한 뒤 지수 완화(lerp)로 순간이동 없이 따라가게 한다.
@include stage($stage-yellow);
.dfh {
position: relative;
display: flex;
align-items: center;
border-radius: $r-control;
background: rgba($subject-ink, .06);
padding: $sp-1;
}
.dfh__btn {
appearance: none;
border: none;
background: transparent;
color: $subject-ink;
font-size: 13px;
font-weight: 700;
padding: $sp-2 $sp-4;
cursor: pointer;
border-radius: $r-sm;
transition: background-color $dur-quick $ease-out;
&:hover, &:focus-visible { background: rgba($subject-ink, .08); }
&:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
}
.dfh__seam {
position: relative;
width: 4px;
height: 28px;
align-self: center;
opacity: 1;
will-change: opacity;
&::before {
content: "";
position: absolute;
inset: 0;
border-radius: $r-xs;
background: rgba($subject-ink, .42);
}
&::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 26px;
height: 26px;
transform: translate(-50%, -50%);
border-radius: 50%;
background: rgba($subject-ink, .14);
filter: blur(5px);
}
}
@media (max-width: 340px) {
.dfh__btn { font-size: 12px; padding: $sp-2 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.dfh__btn { transition: none; }
.dfh__seam { opacity: 1 !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);
}
.dfh {
position: relative;
display: flex;
align-items: center;
border-radius: 12px;
background: rgba(23, 20, 26, 0.06);
padding: 4px;
}
.dfh__btn {
appearance: none;
border: none;
background: transparent;
color: #17141a;
font-size: 13px;
font-weight: 700;
padding: 8px 16px;
cursor: pointer;
border-radius: 8px;
transition: background-color 220ms ease-out;
}
.dfh__btn:hover, .dfh__btn:focus-visible {
background: rgba(23, 20, 26, 0.08);
}
.dfh__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.dfh__seam {
position: relative;
width: 4px;
height: 28px;
align-self: center;
opacity: 1;
will-change: opacity;
}
.dfh__seam::before {
content: "";
position: absolute;
inset: 0;
border-radius: 4px;
background: rgba(23, 20, 26, 0.42);
}
.dfh__seam::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 26px;
height: 26px;
transform: translate(-50%, -50%);
border-radius: 50%;
background: rgba(23, 20, 26, 0.14);
filter: blur(5px);
}
@media (max-width: 340px) {
.dfh__btn {
font-size: 12px;
padding: 8px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.dfh__btn {
transition: none;
}
.dfh__seam {
opacity: 1 !important;
}
}(function () {
var FALLOFF_PX = 46; // 계산 겹 상수: 포인터 거리 값이 이 px 안이면 구분선 투명도가 낮아지기 시작한다
var SMOOTH = 0.22; // 계산 겹 상수: 지수 완화(lerp) 비율 — 값이 순간이동하지 않고 따라간다
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('dfh');
var seams = [document.getElementById('seam0'), document.getElementById('seam1')];
var isDemo = true;
var pointerX = null;
var opac = [1, 1]; // 계산 겹: 각 구분선의 현재 렌더 투명도(완화된 값)
function seamCenters() {
var rootRect = root.getBoundingClientRect();
return seams.map(function (s) {
var r = s.getBoundingClientRect();
return r.left + r.width / 2 - rootRect.left;
});
}
// 계산 겹: 포인터 거리 값 → 투명도 목표값(0~1)을 매끄러운 falloff 곡선으로 계산
function targetOpacity(dist) {
var t = Math.min(1, Math.max(0, dist / FALLOFF_PX));
return t * t * (3 - 2 * t); // smoothstep — 선형보다 자연스러운 감쇠
}
function human() {
if (isDemo) { root.classList.remove('is-demo'); isDemo = false; }
}
root.addEventListener('pointermove', function (e) {
human();
pointerX = e.clientX - root.getBoundingClientRect().left;
});
root.addEventListener('pointerleave', function () { pointerX = null; });
var demoStart = 0;
function demoPointerX(ts, width) {
var t = (ts - demoStart) / 1000;
return width / 2 + Math.sin(t * 1.1) * (width / 2 - 10); // 좌우로 오가는 시뮬레이션 좌표
}
function loop(ts) {
if (!demoStart) demoStart = ts;
var rect = root.getBoundingClientRect();
var px = isDemo && !reduce ? demoPointerX(ts, rect.width) : pointerX;
var centers = seamCenters();
for (var i = 0; i < seams.length; i++) {
var target = px == null ? 1 : targetOpacity(Math.abs(px - centers[i]));
opac[i] += (target - opac[i]) * (reduce ? 1 : SMOOTH);
seams[i].style.opacity = opac[i].toFixed(3);
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// DividerFadeOnHover — vanilla 판(run/369/demo/03-btn-group-divider-fade-on-hover/index.html) 의 React 포트.
// 포인터(x) 와 각 구분선의 실측 중점 사이 거리 값을 매 프레임 재고, smoothstep falloff 로 목표
// 투명도를 구한 뒤 지수 완화(lerp)로 순간이동 없이 따라가게 한다.
import { useEffect, useRef } from "react";
import styles from "./DividerFadeOnHover.module.scss";
type Box<T> = { current: T };
export default function DividerFadeOnHover() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const seamRefs = useRef([]) as unknown as Box<HTMLSpanElement[]>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const FALLOFF_PX = 46; // 계산 겹 상수: 포인터 거리 값이 이 px 안이면 구분선 투명도가 낮아지기 시작한다
const SMOOTH = 0.22; // 계산 겹 상수: 지수 완화(lerp) 비율
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const root = rootRef.current;
const seams = seamRefs.current;
if (!root || seams.length === 0) return;
let isDemo = true;
let pointerX: number | null = null;
const opac = [1, 1];
function seamCenters() {
const rootRect = root!.getBoundingClientRect();
return seams.map((s) => {
const r = s.getBoundingClientRect();
return r.left + r.width / 2 - rootRect.left;
});
}
function targetOpacity(dist: number) {
const t = Math.min(1, Math.max(0, dist / FALLOFF_PX));
return t * t * (3 - 2 * t);
}
function human() { if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; } }
function onMove(e: PointerEvent) { human(); pointerX = e.clientX - root!.getBoundingClientRect().left; }
function onLeave() { pointerX = null; }
root.addEventListener("pointermove", onMove);
root.addEventListener("pointerleave", onLeave);
let demoStart = 0;
function demoPointerX(ts: number, width: number) {
const t = (ts - demoStart) / 1000;
return width / 2 + Math.sin(t * 1.1) * (width / 2 - 10);
}
function loop(ts: number) {
if (!demoStart) demoStart = ts;
const rect = root!.getBoundingClientRect();
const px = isDemo && !reduce ? demoPointerX(ts, rect.width) : pointerX;
const centers = seamCenters();
for (let i = 0; i < seams.length; i++) {
const target = px == null ? 1 : targetOpacity(Math.abs(px - centers[i]));
opac[i] += (target - opac[i]) * (reduce ? 1 : SMOOTH);
seams[i].style.opacity = opac[i].toFixed(3);
}
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
root.removeEventListener("pointermove", onMove);
root.removeEventListener("pointerleave", onLeave);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.dfh + " " + styles.isDemo} ref={rootRef} role="group" aria-label="보기 전환">
<button className={styles.btn}>그리드</button>
<span className={styles.seam} ref={(el: HTMLSpanElement | null) => { if (el) seamRefs.current[0] = el; }} aria-hidden="true" />
<button className={styles.btn}>리스트</button>
<span className={styles.seam} ref={(el: HTMLSpanElement | null) => { if (el) seamRefs.current[1] = el; }} aria-hidden="true" />
<button className={styles.btn}>카드</button>
</div>
</main>
);
}03 区切り線フェード — ボタン間の区切り線の透明度の値がポインターが隣のボタンに近づくほど薄くなる方式 — 計測 累積面積 0.297% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · ボタン グループ ui 9選 — 隣り合うボタンが一つに動く仕組み
このデモが属する記事
ボタン グループ ui 9選 — 隣り合うボタンが一つに動く仕組みボタン グループ ui: 9つのうち1つはアクティブボタンの値が変わるとスライド表示子の位置の値が実測座標に沿って移動し、1つはグループの幅の値が縮むと溢れたボタンがもっと見る値の中に折り畳まれ、もう1つは方向キーでフォーカスの値が移る際に この記事のデモをすべて見る


