02Split offset — a button whose stacked text layers momentarily offset in position value before realigning
The moment a hovered state begins, the position value of two stacked text layers offsets by a set distance for an instant, then returns to the same position value once a set duration passes.
Best forAn event-highlight button, a limited-edition product 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">Buy limited edition</span>
<span class="ctagls__layer ctagls__layer--b" id="ctaglsB" aria-hidden="true">Buy limited edition</span>
</button>
<span class="ctagls__hint">Hover to briefly offset the text layers</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 Split offset — a button whose stacked text layers momentarily offset in position value before realigning — Measured area 0.914% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · CTA Button Design: 9 Interaction Patterns
The post this demo lives in
CTA Button Design: 9 Interaction PatternsOne shadow length value grows and shrinks with hover state, one perimeter progress value depletes to zero over time, and See all demos in this post
If you like this effect - similar demos
Buttons05 Split-dual value reveal — when a confirm value turns on, the button splits into two pieces, each revealing a different valueExpanding Button UI: 9 Shape-Morph Patterns
Buttons02 Active-indicator slide — when the active-button value changes, the highlight background's position value glides to the new button along measured coordinatesButton Group CSS: 9 Adjoined Patterns
Buttons09 Noise Button That Turns to Static When an Error Value FlipsButton Click Effect CSS: 9 Canvas Pixel Patterns