09下線アニメーションリンクボタン
リンク型ボタンの文字の下の下線が、ホバー時に中央から左右へ伸びて満ちる。
使いどころテキストリンクCTA・フォーム送信ボタン
transform: scaleXtransform-origin: centerfocus-visible 링

<main class="stage">
<div class="link-group">
<button class="link-btn is-demo" type="button">
<span class="link-btn__label">詳しく見る</span>
</button>
<input class="input-static" type="text" placeholder="メールアドレス" readonly>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 밑줄이 가운데서 자랐다 사그라드는 주기
$easing: $ease-spring;
$color: $subject-blue; // 밑줄·포커스 링 색
@include stage($stage-ink);
.link-group {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: $sp-6;
}
.link-btn {
position: relative;
border: 0;
background: none;
padding: $sp-2 0;
cursor: pointer;
color: $subject-cream;
}
.link-btn__label {
font-weight: 800;
font-size: clamp(18px, 5vw, 26px);
line-height: 1;
}
// 밑줄이 가운데(transform-origin: center)에서 시작해 양쪽으로 자랐다 사그라든다
.link-btn::after {
content: '';
position: absolute;
left: 0; right: 0; bottom: 0;
height: 2px;
background: $color;
transform-origin: center;
transform: scaleX(0);
}
.link-btn.is-demo::after {
animation: underline-pulse $duration $easing infinite;
}
@keyframes underline-pulse {
0%, 10% { transform: scaleX(0); }
50% { transform: scaleX(1); }
90%, 100% { transform: scaleX(0); }
}
.link-btn:focus-visible {
outline: 2px solid $color;
outline-offset: $sp-1;
}
// 버튼 라벨 글자 낱개 — index.html 말미 스크립트가 텍스트를 이 span 으로 쪼개고
// 결정론 해시로 --nx0·--ny0·--nr0(출발점) · --wx·--wy·--wr(힘장 경유점) 을 심는다. (형태2차 한 겹, 077 품질기준선)
.char {
display: inline-block;
animation: char-flow $duration $easing infinite;
}
.char--space { width: .28em; }
@keyframes char-flow {
0%, 4% { transform: translate(var(--nx0, 0), var(--ny0, 16px)) rotate(var(--nr0, 0deg)) scale(.55); opacity: 0; }
16% { transform: translate(var(--wx, 0), var(--wy, 4px)) rotate(var(--wr, 0deg)) scale(1.08); opacity: 1; }
30%, 78% { transform: translate(0, 0) rotate(0deg) scale(1); opacity: 1; }
96%, 100% { transform: translate(var(--nx0, 0), var(--ny0, 16px)) rotate(var(--nr0, 0deg)) scale(.55); opacity: 0; }
}
.input-static {
border: 1px solid rgba($subject-cream, .3);
border-radius: $r-control;
background: transparent;
color: $subject-cream;
padding: $sp-2 $sp-3;
font-weight: 500;
font-size: clamp(12px, 3vw, 14px);
line-height: 1;
}
.input-static::placeholder {
color: rgba($subject-cream, .5);
}
@media (prefers-reduced-motion: reduce) {
.link-btn.is-demo::after { animation: none; transform: scaleX(0); }
.char { animation: none; transform: none; opacity: 1; }
}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);
}
.link-group {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 24px;
}
.link-btn {
position: relative;
border: 0;
background: none;
padding: 8px 0;
cursor: pointer;
color: #fff7e6;
}
.link-btn__label {
font-weight: 800;
font-size: clamp(18px, 5vw, 26px);
line-height: 1;
}
.link-btn::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
background: #2f6df6;
transform-origin: center;
transform: scaleX(0);
}
.link-btn.is-demo::after {
animation: underline-pulse 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes underline-pulse {
0%, 10% {
transform: scaleX(0);
}
50% {
transform: scaleX(1);
}
90%, 100% {
transform: scaleX(0);
}
}
.link-btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 4px;
}
.char {
display: inline-block;
animation: char-flow 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.char--space {
width: 0.28em;
}
@keyframes char-flow {
0%, 4% {
transform: translate(var(--nx0, 0), var(--ny0, 16px)) rotate(var(--nr0, 0deg)) scale(0.55);
opacity: 0;
}
16% {
transform: translate(var(--wx, 0), var(--wy, 4px)) rotate(var(--wr, 0deg)) scale(1.08);
opacity: 1;
}
30%, 78% {
transform: translate(0, 0) rotate(0deg) scale(1);
opacity: 1;
}
96%, 100% {
transform: translate(var(--nx0, 0), var(--ny0, 16px)) rotate(var(--nr0, 0deg)) scale(0.55);
opacity: 0;
}
}
.input-static {
border: 1px solid rgba(255, 247, 230, 0.3);
border-radius: 12px;
background: transparent;
color: #fff7e6;
padding: 8px 12px;
font-weight: 500;
font-size: clamp(12px, 3vw, 14px);
line-height: 1;
}
.input-static::placeholder {
color: rgba(255, 247, 230, 0.5);
}
@media (prefers-reduced-motion: reduce) {
.link-btn.is-demo::after {
animation: none;
transform: scaleX(0);
}
.char {
animation: none;
transform: none;
opacity: 1;
}
}// 버튼 라벨 글자별 노이즈 오프셋 + 벡터필드 진입 (형태2차) — 라벨을 span 으로 쪼개고
// 결정론 해시로 출발점/경유점 변수만 심는다. 프레임은 style.scss 의 char-flow 키프레임이 つくる。.
// 기존 underline-pulse 와 겹쳐 — 글자가 흩어졌다 힘장을 따라 자리 잡고, 그 아래 밑줄이 양쪽으로 자란다.
(function () {
function hash(n) { // 정수 → 0..1 결정론 난수 (sin 해시)
var s = Math.sin(n * 127.1 + 311.7) * 43758.5453;
return s - Math.floor(s);
}
document.querySelectorAll('.link-btn .link-btn__label').forEach(function (label) {
var text = label.textContent;
label.textContent = '';
for (var c = 0; c < text.length; c++) {
var ch = document.createElement('span');
if (text[c] === ' ') { ch.className = 'char char--space'; }
else {
ch.className = 'char';
ch.textContent = text[c];
var s = c * 13 + 5;
var h1 = hash(s), h2 = hash(s + 1), h3 = hash(s + 2), h4 = hash(s + 3);
var ang = h1 * Math.PI * 2;
var rad = 12 + h2 * 22;
var x0 = Math.cos(ang) * rad, y0 = 16 + h3 * 14;
var rot0 = (h4 - .5) * 30;
var wx = x0 - 0.9 * y0 * 0.55, wy = y0 + 0.9 * x0 * 0.55;
var wr = rot0 * 0.4;
ch.style.setProperty('--nx0', x0.toFixed(1) + 'px');
ch.style.setProperty('--ny0', y0.toFixed(1) + 'px');
ch.style.setProperty('--nr0', rot0.toFixed(1) + 'deg');
ch.style.setProperty('--wx', (wx * .6).toFixed(1) + 'px');
ch.style.setProperty('--wy', (wy * .18 + 3).toFixed(1) + 'px');
ch.style.setProperty('--wr', wr.toFixed(1) + 'deg');
ch.style.animationDelay = (-0.045 * c).toFixed(3) + 's';
}
label.appendChild(ch);
}
});
})();import type { CSSProperties } from "react";
import styles from "./KineticButtonsInputs.module.scss";
export type KineticButtonsInputsProps = {
label?: string;
placeholder?: string;
/** 밑줄이 가운데서 자랐다 사그라드는 주기(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type CharVars = CSSProperties & {
"--nx0"?: string; "--ny0"?: string; "--nr0"?: string;
"--wx"?: string; "--wy"?: string; "--wr"?: string;
};
/** 정수 → 0..1 결정론 난수 (sin 해시 — vanilla 09 와 동일 계열) */
function hash(n: number): number {
const s = Math.sin(n * 127.1 + 311.7) * 43758.5453;
return s - Math.floor(s);
}
/** 한 글자 = 출발점(노이즈) + 힘장 경유점 변수 묶음. vanilla index.html 스크립트와 같은 식. */
function charVars(c: number): CharVars {
const s = c * 13 + 5;
const h1 = hash(s), h2 = hash(s + 1), h3 = hash(s + 2), h4 = hash(s + 3);
const ang = h1 * Math.PI * 2;
const rad = 12 + h2 * 22;
const x0 = Math.cos(ang) * rad, y0 = 16 + h3 * 14;
const rot0 = (h4 - .5) * 30;
// 회전 힘장 v(p) = ω × p 를 출발점에서 dt 적분 — v = ω*(-y, x), dt=0.55, ω=0.9
const wx = x0 - 0.9 * y0 * 0.55, wy = y0 + 0.9 * x0 * 0.55;
const wr = rot0 * 0.4;
return {
"--nx0": `${x0.toFixed(1)}px`,
"--ny0": `${y0.toFixed(1)}px`,
"--nr0": `${rot0.toFixed(1)}deg`,
"--wx": `${(wx * .6).toFixed(1)}px`,
"--wy": `${(wy * .18 + 3).toFixed(1)}px`,
"--wr": `${wr.toFixed(1)}deg`,
animationDelay: `${(-0.045 * c).toFixed(3)}s`,
};
}
export default function KineticButtonsInputs({
label = "Learn more",
placeholder = "Email address",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: KineticButtonsInputsProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stage} style={vars}>
<div className={styles.group}>
<button type="button" className={styles.linkBtn}>
<span className={styles.label}>
{label.split("").map((ch, c) =>
ch === " " ? (
<span key={c} className={styles.charSpace} />
) : (
<span key={c} className={styles.char} style={charVars(c)}>{ch}</span>
)
)}
</span>
<span className={styles.underline} aria-hidden="true" />
</button>
<input className={styles.input} type="text" placeholder={placeholder} readOnly />
</div>
</div>
);
}09 下線アニメーションリンクボタン — 計測 累積面積 2.883% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · キネティックタイポグラフィをコードで分解してみた
このデモが属する記事
キネティックタイポグラフィをコードで分解してみたキネティックタイポグラフィとは、見出しの文字サイズが画面幅に合わせて変わり、単語が順番に浮かび上がり、数字がオドメーターのように上がっていくデザイン手法のことです。最近のランディングページはどれもファーストビューがこれなので、実際にコードを この記事のデモをすべて見る


