02ずれ分離 — 重なったテキスト層の位置の値が一瞬ずれてから再び重なるボタン
ホバー状態になった瞬間、重なった2つのテキスト層の位置の値が決まった幅だけ一瞬ずれ、決まった時間が過ぎると再び同じ位置の値に重なります。
使いどころイベント強調ボタン、限定商品CTA
SEED=20260920DURATION=320mulberry32

<main class="stage">
<div class="ctagls is-demo" id="ctagls">
<div class="ctagls__panel">
<button class="ctagls__btn" id="ctaglsBtn" type="button">
<span class="ctagls__layer ctagls__layer--a" id="ctaglsA">限定版を購入</span>
<span class="ctagls__layer ctagls__layer--b" id="ctaglsB" aria-hidden="true">限定版を購入</span>
</button>
<span class="ctagls__hint">ホバーするとテキスト層が一瞬ずれます</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(시드 난수 어긋남)은 JS 에 있다 — 여기는 정적 배치·색만.
@include stage($stage-ink);
.stage { position: relative; }
.ctagls__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
padding: $sp-5;
border-radius: $r-card;
background: $tick-panel;
}
.ctagls__btn {
position: relative;
border: 0;
border-radius: $r-control;
background: $subject-pink;
padding: $sp-4 $sp-5;
cursor: pointer;
overflow: hidden;
}
.ctagls__layer {
display: block;
font-family: inherit;
font-weight: 700;
font-size: 16px;
color: $subject-ink;
white-space: nowrap;
}
.ctagls__layer--b {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: $subject-blue-light;
mix-blend-mode: screen;
}
.ctagls__hint {
color: $tick-mute;
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.ctagls__panel { padding: $sp-4; gap: $sp-3; }
.ctagls__btn { padding: $sp-3 $sp-4; }
.ctagls__layer { font-size: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.ctagls__layer { 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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.ctagls__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 20px;
border-radius: 16px;
background: #231f28;
}
.ctagls__btn {
position: relative;
border: 0;
border-radius: 12px;
background: #ff9ebb;
padding: 16px 20px;
cursor: pointer;
overflow: hidden;
}
.ctagls__layer {
display: block;
font-family: inherit;
font-weight: 700;
font-size: 16px;
color: #17141a;
white-space: nowrap;
}
.ctagls__layer--b {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #5b8cf8;
mix-blend-mode: screen;
}
.ctagls__hint {
color: #9a93a6;
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.ctagls__panel {
padding: 16px;
gap: 12px;
}
.ctagls__btn {
padding: 12px 16px;
}
.ctagls__layer {
font-size: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.ctagls__layer {
transition: none !important;
}
}(function () {
var root = document.getElementById('ctagls');
var btn = document.getElementById('ctaglsBtn');
var layerA = document.getElementById('ctaglsA');
var layerB = document.getElementById('ctaglsB');
// 계산 겹: mulberry32 시드 난수로 어긋남 폭을 매 틱 정하고, 정해진 시간(320ms) 동안
// TICK 간격으로 흔들다가 마지막엔 반드시 0 으로 되돌린다 — 재생마다 같은 시드로 같은 패턴.
var SEED = 20260920;
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rng = mulberry32(SEED);
var DURATION = 320, TICK = 40;
var glitchTimer = null;
function runGlitch() {
if (glitchTimer) { return; }
var elapsed = 0;
glitchTimer = setInterval(function () {
elapsed += TICK;
if (elapsed >= DURATION) {
clearInterval(glitchTimer); glitchTimer = null;
layerA.style.transform = 'translate(0px,0px)';
layerB.style.transform = 'translate(0px,0px)';
return;
}
var ax = (rng() - 0.5) * 10;
var ay = (rng() - 0.5) * 4;
var bx = (rng() - 0.5) * 10;
var by = (rng() - 0.5) * 4;
layerA.style.transform = 'translate(' + ax.toFixed(1) + 'px,' + ay.toFixed(1) + 'px)';
layerB.style.transform = 'translate(' + bx.toFixed(1) + 'px,' + by.toFixed(1) + 'px)';
}, TICK);
}
function wireReal() {
btn.addEventListener('mouseenter', function () { root.classList.remove('is-demo'); stopAutoplay(); runGlitch(); });
btn.addEventListener('focus', function () { root.classList.remove('is-demo'); stopAutoplay(); runGlitch(); });
btn.addEventListener('click', function () { runGlitch(); });
}
wireReal();
var autoplayTimer = null;
function stopAutoplay() { if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { autoplayTimer = setInterval(runGlitch, 900); }
})();import { useEffect, useRef } from "react";
import styles from "./CtaBtnGlitchSplit.module.scss";
export type CtaBtnGlitchSplitProps = {
color?: string;
};
export default function CtaBtnGlitchSplit({
color = "#5b8cf8",
}: CtaBtnGlitchSplitProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const btn = root.querySelector<HTMLButtonElement>(`.${styles.btn}`);
const layerA = root.querySelector<HTMLSpanElement>(`.${styles.layerA}`);
const layerB = root.querySelector<HTMLSpanElement>(`.${styles.layerB}`);
if (!btn || !layerA || !layerB) return;
// 계산 겹: mulberry32 시드 난수로 어긋남 폭을 매 틱 정하고, 정해진 시간(320ms) 동안
// TICK 간격으로 흔들다가 마지막엔 반드시 0 으로 되돌린다 — 재생마다 같은 시드로 같은 패턴.
const SEED = 20260920;
function mulberry32(a: number) {
return function () {
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const rng = mulberry32(SEED);
const DURATION = 320, TICK = 40;
let glitchTimer: number | null = null;
function runGlitch() {
if (glitchTimer !== null) return;
let elapsed = 0;
glitchTimer = window.setInterval(() => {
elapsed += TICK;
if (elapsed >= DURATION) {
if (glitchTimer !== null) { window.clearInterval(glitchTimer); glitchTimer = null; }
layerA!.style.transform = "translate(0px,0px)";
layerB!.style.transform = "translate(0px,0px)";
return;
}
const ax = (rng() - 0.5) * 10;
const ay = (rng() - 0.5) * 4;
const bx = (rng() - 0.5) * 10;
const by = (rng() - 0.5) * 4;
layerA!.style.transform = "translate(" + ax.toFixed(1) + "px," + ay.toFixed(1) + "px)";
layerB!.style.transform = "translate(" + bx.toFixed(1) + "px," + by.toFixed(1) + "px)";
}, TICK);
}
function onEnter() { stopAutoplay(); runGlitch(); }
function onClick() { runGlitch(); }
btn.addEventListener("mouseenter", onEnter);
btn.addEventListener("focus", onEnter);
btn.addEventListener("click", onClick);
let autoplayTimer: number | null = null;
function stopAutoplay() { if (autoplayTimer !== null) { window.clearInterval(autoplayTimer); autoplayTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoplayTimer = window.setInterval(runGlitch, 900); }
return () => {
stopAutoplay();
if (glitchTimer !== null) { window.clearInterval(glitchTimer); }
btn.removeEventListener("mouseenter", onEnter);
btn.removeEventListener("focus", onEnter);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.panel}>
<button className={styles.btn} type="button">
<span className={styles.layerA}>한정판 구매</span>
<span className={styles.layerB} aria-hidden="true" style={{ color }}>한정판 구매</span>
</button>
<span className={styles.hint}>호버하면 텍스트 층이 잠깐 어긋납니다</span>
</div>
</div>
);
}02 ずれ分離 — 重なったテキスト層の位置の値が一瞬ずれてから再び重なるボタン — 計測 累積面積 0.914% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · CTA ボタン デザイン 9選 — 伸びる影から跳ねるバーまで
このデモが属する記事
CTA ボタン デザイン 9選 — 伸びる影から跳ねるバーまで9つのうち1つは伸びる影の長さの値がホバー状態に応じて伸び縮みし、1つは周囲の進行の値が決まった時間の間に0まで減り、もう1つは引いた位置の値が基準を超えると確定した状態に変わります。cta ボタン デザインの9つの動きを見ていきます。 この記事のデモをすべて見る


