【一行でコピペOK】CSS 枠線 アニメーション 9選
css 枠線 アニメーションは、カードや入力欄の外側の線だけを動かして「今なにが起きているか」を伝える技法です。今回は9種類を選び、光の縞を流すもの、SVGで線を描くもの、色相だけを回すものまで、マスク・SVG・アウトライン・@propertyと、それぞれ違う仕組みで組みました。
01 border-animation-rotating-gradient-border
02 border-animation-draw-border-svg
03 border-animation-dashed-run
04 border-animation-glow-pulse-border
05 border-animation-corner-brackets
06 border-animation-underline-grow
07 border-animation-double-ring
08 border-animation-rainbow-border
09 border-animation-focus-ring-animate
自動再生 · タイルを押すとその項目へ · すべて1つのzip
並び順は派手さではなく、枠が何を知らせているかに沿っています。まだ何かが進行中だと伝える4つ(01〜04)を先に置き、続けて「css 入力フォーム 枠」のように選択やフォーカスの場所を示す2つ(05・06)、ホバーで強調する1つ(07)、特別な瞬間を祝う1つ(08)を経て、最後はキーボード操作のためのフォーカスリング(09)で締めくくりました。各項目のコードは仕組みを決める部分だけを載せていて、セットアップまで含めた完成ファイルはzipにまとめてあります。枠ではなく押した瞬間そのものを知らせたい場所にはCSS タップ エフェクトが向いていて、01のスイープの下地になっている基本の動き自体はCSS キーフレーム アニメーションに一つずつ分けて置いてあります。
01斜めハイライトスイープ枠
枠の形だけに切り抜いたリング
枠の形だけに切り抜いた薄いリング(.frame::before)に斜めの縞グラデーションを敷き、background-positionを流し続けることで枠全体が一斉に光って流れているように見えます。AIが応答を生成している間の入力欄や、プレミアムなカードの枠に向いています。

<main class="stage">
<div class="frame is-demo">
<input class="frame__input" type="text" placeholder="メッセージを入力" readonly>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 줄무늬가 한 바퀴 흐르는 시간
$easing: linear; // 흐름은 일정 속도로
$color: $subject-blue; // 줄무늬 색
@include stage($stage-ink);
.stage { padding: clamp(6px, 2.4vw, 16px); }
.frame {
position: relative;
width: min(230px, 74vw);
border-radius: $r-control;
padding: 3px;
isolation: isolate;
}
// 테두리 자리에만 보이게 잘라낸 링 위에 대각선 줄무늬를 흘려보낸다
.frame::before {
content: "";
position: absolute;
inset: 0;
border-radius: $r-control;
padding: 3px;
background: repeating-linear-gradient(115deg, $color 0 10px, $subject-cream 10px 14px, transparent 14px 34px);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor; // 옛 사파리
mask-composite: exclude; // 표준 (mask 무접두사 지원표: blog-topic 편브리프)
}
.frame.is-demo::before,
.frame:focus-within::before { animation: sweep $duration $easing infinite; }
.frame__input {
position: relative;
display: block;
box-sizing: border-box;
width: 100%;
border: 0;
border-radius: $r-control - 3px;
padding: 14px 16px;
font-weight: 500; font-size: clamp(13px, 3.6vw, 15px); line-height: 1.3; font-family: inherit;
color: $subject-cream;
background: $stage-ink;
}
.frame__input::placeholder { color: rgba($subject-cream, .55); }
@keyframes sweep { to { background-position: 220px 0; } }
@media (prefers-reduced-motion: reduce) {
.frame.is-demo::before, .frame:focus-within::before { animation: none; }
}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 {
padding: clamp(6px, 2.4vw, 16px);
}
.frame {
position: relative;
width: min(230px, 74vw);
border-radius: 12px;
padding: 3px;
isolation: isolate;
}
.frame::before {
content: "";
position: absolute;
inset: 0;
border-radius: 12px;
padding: 3px;
background: repeating-linear-gradient(115deg, #2f6df6 0 10px, #fff7e6 10px 14px, transparent 14px 34px);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
}
.frame.is-demo::before,
.frame:focus-within::before {
animation: sweep 2s linear infinite;
}
.frame__input {
position: relative;
display: block;
box-sizing: border-box;
width: 100%;
border: 0;
border-radius: 9px;
padding: 14px 16px;
font-weight: 500;
font-size: clamp(13px, 3.6vw, 15px);
line-height: 1.3;
font-family: inherit;
color: #fff7e6;
background: #17141a;
}
.frame__input::placeholder {
color: rgba(255, 247, 230, 0.55);
}
@keyframes sweep {
to {
background-position: 220px 0;
}
}
@media (prefers-reduced-motion: reduce) {
.frame.is-demo::before, .frame:focus-within::before {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./SweepBorderInput.module.scss";
export type SweepBorderInputProps = {
placeholder: string;
/** 줄무늬가 한 바퀴 흐르는 시간 (기본 2000ms) */
duration?: number;
easing?: string;
/** 줄무늬 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SweepBorderInput({
placeholder,
duration = 2000,
easing = "linear",
color = "#2f6df6",
}: SweepBorderInputProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.frame} style={vars}>
<input className={styles.input} type="text" placeholder={placeholder} readOnly />
</div>
);
}01 · 斜めハイライトスイープ枠 — 計測 累積面積 1.167% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
AI応答中の入力欄に
.frame::before {
content: "";
position: absolute;
inset: 0;
padding: 3px;
background: repeating-linear-gradient(115deg, $color 0 10px, $subject-cream 10px 14px, transparent 14px 34px);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor; // 旧Safari向け
mask-composite: exclude; // 標準
}
.frame.is-demo::before,
.frame:focus-within::before {
animation: sweep $duration $easing infinite;
}
@keyframes sweep { to { background-position: 220px 0; } }
02SVG枠線描画
pathLengthで全周を100にする
pathLength="100"を指定したSVGのrectにstroke-dasharrayで全周を100として登録し、stroke-dashoffsetを100から0へ減らすとペンで描くように枠がつながります。カードが読み込まれた瞬間に一度だけ発火する.is-drawnと、常時ループする.is-demoの2系統を同じ.card__rectに用意しました。

<main class="stage">
<div class="card is-demo">
<svg class="card__svg" viewBox="0 0 220 130" preserveAspectRatio="none" aria-hidden="true">
<rect class="card__rect" x="2" y="2" width="216" height="126" rx="14" pathLength="100"></rect>
</svg>
<div class="card__body">
<h3 class="card__title">カード発行</h3>
<p class="card__meta">本人確認完了</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter; // 실제 트리거(등장) 시 테두리가 다 그려지는 시간
$easing: $ease-out;
$color: $subject-ink; // 선 색
@include stage($stage-yellow);
.stage { padding: clamp(6px, 2.4vw, 16px); }
.card {
position: relative;
width: min(220px, 70vw);
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-raised;
}
.card__svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.card__rect {
fill: none;
stroke: $color;
stroke-width: 3;
stroke-dasharray: 100; // pathLength="100" 이라 둘레와 무관하게 0~100 단위로 잰다
stroke-dashoffset: 100;
}
// 등장 시 1회는 이 값으로 (예: 카드가 로드된 직후 JS 로 .is-drawn 을 붙인다)
.card.is-drawn .card__rect { animation: draw $duration $easing forwards; }
// 데모 · 문서용 루프: 2초 안에 그려짐 → 유지 → 지워짐을 반복
.card.is-demo .card__rect { animation: draw-loop $dur-loop $easing infinite; }
.card__body { position: relative; padding: 20px 16px; }
.card__title { margin: 0 0 4px; font-weight: 700; font-size: clamp(13px, 3.6vw, 15px); line-height: 1.3; font-family: inherit; color: $stage-ink; }
.card__meta { margin: 0; font-weight: 500; font-size: clamp(11px, 3vw, 12px); line-height: 1.3; font-family: inherit; color: rgba($stage-ink, .6); }
@keyframes draw { to { stroke-dashoffset: 0; } }
@keyframes draw-loop {
0%, 5% { stroke-dashoffset: 100; }
35%, 60% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: -100; }
}
@media (prefers-reduced-motion: reduce) {
.card.is-drawn .card__rect, .card.is-demo .card__rect { animation: none; stroke-dashoffset: 0; }
}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);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
.card {
position: relative;
width: min(220px, 70vw);
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.card__svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.card__rect {
fill: none;
stroke: #17141a;
stroke-width: 3;
stroke-dasharray: 100;
stroke-dashoffset: 100;
}
.card.is-drawn .card__rect {
animation: draw 600ms ease-out forwards;
}
.card.is-demo .card__rect {
animation: draw-loop 2s ease-out infinite;
}
.card__body {
position: relative;
padding: 20px 16px;
}
.card__title {
margin: 0 0 4px;
font-weight: 700;
font-size: clamp(13px, 3.6vw, 15px);
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.card__meta {
margin: 0;
font-weight: 500;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.3;
font-family: inherit;
color: rgba(23, 20, 26, 0.6);
}
@keyframes draw {
to {
stroke-dashoffset: 0;
}
}
@keyframes draw-loop {
0%, 5% {
stroke-dashoffset: 100;
}
35%, 60% {
stroke-dashoffset: 0;
}
100% {
stroke-dashoffset: -100;
}
}
@media (prefers-reduced-motion: reduce) {
.card.is-drawn .card__rect, .card.is-demo .card__rect {
animation: none;
stroke-dashoffset: 0;
}
}import type { CSSProperties } from "react";
import styles from "./DrawBorderCard.module.scss";
export type DrawBorderCardProps = {
title: string;
meta: string;
/** 테두리가 다 그려지는 시간 (기본 2000ms, 루프용) */
duration?: number;
easing?: string;
/** 선 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function DrawBorderCard({
title,
meta,
duration = 2000,
easing = "ease-out",
color = "#17141a",
}: DrawBorderCardProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.card} style={vars}>
<svg className={styles.svg} viewBox="0 0 220 130" preserveAspectRatio="none" aria-hidden="true">
<rect className={styles.rect} x="2" y="2" width="216" height="126" rx="14" pathLength={100} />
</svg>
<div className={styles.body}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.meta}>{meta}</p>
</div>
</div>
);
}02 · SVG枠線描画 — 計測 累積面積 1.148% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
一度だけとループの2系統
.card__rect {
fill: none;
stroke: $color;
stroke-width: 3;
stroke-dasharray: 100; // pathLengthのおかげで周長と無関係に0〜100で扱える
stroke-dashoffset: 100;
}
.card.is-drawn .card__rect { animation: draw $duration $easing forwards; }
.card.is-demo .card__rect { animation: draw-loop $dur-loop $easing infinite; }
@keyframes draw { to { stroke-dashoffset: 0; } }
@keyframes draw-loop {
0%, 5% { stroke-dashoffset: 100; }
35%, 60% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: -100; }
}
03破線が走る
四辺を別々のkeyframesで動かす
上下左右の.frame__edgeを4つの独立した要素として置き、横の辺はbackground-positionを横方向、縦の辺は縦方向へ別々のkeyframesで動かすことで、破線ひとつながりが時計回りに進んでいるように見せています。アップロード中のカードや、順番待ちの状態表示に向いています。

<main class="stage">
<div class="frame is-demo">
<span class="frame__edge frame__edge--top" aria-hidden="true"></span>
<span class="frame__edge frame__edge--right" aria-hidden="true"></span>
<span class="frame__edge frame__edge--bottom" aria-hidden="true"></span>
<span class="frame__edge frame__edge--left" aria-hidden="true"></span>
<div class="frame__body">
<p class="frame__label">アップロード中</p>
<p class="frame__pct">42%</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 한 바퀴 도는 시간
$easing: linear;
$color: $subject-cream; // 점선 색
@include stage($stage-orange);
.stage { padding: clamp(6px, 2.4vw, 16px); }
.frame {
position: relative;
width: min(220px, 70vw);
border-radius: 10px;
}
// 네 변을 따로 두고 각자의 방향으로 점선 배경을 흘려보낸다 — 한꺼번에 돌면 시계 방향으로 도는 것처럼 보인다
.frame__edge { position: absolute; }
.frame__edge--top, .frame__edge--bottom {
left: 10px; right: 10px; height: 3px;
background-image: repeating-linear-gradient(90deg, $color 0 8px, transparent 8px 16px);
}
.frame__edge--left, .frame__edge--right {
top: 10px; bottom: 10px; width: 3px;
background-image: repeating-linear-gradient(180deg, $color 0 8px, transparent 8px 16px);
}
.frame__edge--top { top: 0; }
.frame__edge--bottom { bottom: 0; }
.frame__edge--left { left: 0; }
.frame__edge--right { right: 0; }
.frame.is-demo .frame__edge--top { animation: run-x $duration $easing infinite; }
.frame.is-demo .frame__edge--bottom { animation: run-x-rev $duration $easing infinite; }
.frame.is-demo .frame__edge--left { animation: run-y-rev $duration $easing infinite; }
.frame.is-demo .frame__edge--right { animation: run-y $duration $easing infinite; }
@keyframes run-x { to { background-position: 32px 0; } }
@keyframes run-x-rev { to { background-position: -32px 0; } }
@keyframes run-y { to { background-position: 0 32px; } }
@keyframes run-y-rev { to { background-position: 0 -32px; } }
.frame__body { padding: 22px 18px; }
.frame__label { margin: 0 0 4px; font-weight: 700; font-size: clamp(13px, 3.6vw, 15px); line-height: 1.3; font-family: inherit; color: $subject-ink; }
.frame__pct { margin: 0; font-weight: 500; font-size: clamp(11px, 3vw, 12px); line-height: 1.3; font-family: inherit; color: $subject-ink; }
@media (prefers-reduced-motion: reduce) {
.frame.is-demo .frame__edge { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
.frame {
position: relative;
width: min(220px, 70vw);
border-radius: 10px;
}
.frame__edge {
position: absolute;
}
.frame__edge--top, .frame__edge--bottom {
left: 10px;
right: 10px;
height: 3px;
background-image: repeating-linear-gradient(90deg, #fff7e6 0 8px, transparent 8px 16px);
}
.frame__edge--left, .frame__edge--right {
top: 10px;
bottom: 10px;
width: 3px;
background-image: repeating-linear-gradient(180deg, #fff7e6 0 8px, transparent 8px 16px);
}
.frame__edge--top {
top: 0;
}
.frame__edge--bottom {
bottom: 0;
}
.frame__edge--left {
left: 0;
}
.frame__edge--right {
right: 0;
}
.frame.is-demo .frame__edge--top {
animation: run-x 2s linear infinite;
}
.frame.is-demo .frame__edge--bottom {
animation: run-x-rev 2s linear infinite;
}
.frame.is-demo .frame__edge--left {
animation: run-y-rev 2s linear infinite;
}
.frame.is-demo .frame__edge--right {
animation: run-y 2s linear infinite;
}
@keyframes run-x {
to {
background-position: 32px 0;
}
}
@keyframes run-x-rev {
to {
background-position: -32px 0;
}
}
@keyframes run-y {
to {
background-position: 0 32px;
}
}
@keyframes run-y-rev {
to {
background-position: 0 -32px;
}
}
.frame__body {
padding: 22px 18px;
}
.frame__label {
margin: 0 0 4px;
font-weight: 700;
font-size: clamp(13px, 3.6vw, 15px);
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.frame__pct {
margin: 0;
font-weight: 500;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
@media (prefers-reduced-motion: reduce) {
.frame.is-demo .frame__edge {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./DashedRunFrame.module.scss";
export type DashedRunFrameProps = {
label: string;
pct: string;
/** 한 바퀴 도는 시간 (기본 2000ms) */
duration?: number;
easing?: string;
/** 점선 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function DashedRunFrame({
label,
pct,
duration = 2000,
easing = "linear",
color = "#fff7e6",
}: DashedRunFrameProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.frame} style={vars}>
<span className={`${styles.edge} ${styles.top}`} aria-hidden="true" />
<span className={`${styles.edge} ${styles.right}`} aria-hidden="true" />
<span className={`${styles.edge} ${styles.bottom}`} aria-hidden="true" />
<span className={`${styles.edge} ${styles.left}`} aria-hidden="true" />
<div className={styles.body}>
<p className={styles.label}>{label}</p>
<p className={styles.pct}>{pct}</p>
</div>
</div>
);
}03 · 破線が走る — 計測 累積面積 1.1% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
アップロード中の表示に
.frame__edge--top, .frame__edge--bottom {
height: 3px;
background-image: repeating-linear-gradient(90deg, $color 0 8px, transparent 8px 16px);
}
.frame__edge--left, .frame__edge--right {
width: 3px;
background-image: repeating-linear-gradient(180deg, $color 0 8px, transparent 8px 16px);
}
.frame.is-demo .frame__edge--top { animation: run-x $duration $easing infinite; }
.frame.is-demo .frame__edge--bottom { animation: run-x-rev $duration $easing infinite; }
.frame.is-demo .frame__edge--left { animation: run-y-rev $duration $easing infinite; }
.frame.is-demo .frame__edge--right { animation: run-y $duration $easing infinite; }
@keyframes run-x { to { background-position: 32px 0; } }
@keyframes run-y { to { background-position: 0 32px; } }
04グローパルス枠
型を登録して補間させる
@propertyで<length>として型を登録した--glowをbox-shadowのぼかし半径へ渡すと、0pxから16pxの間をコマ落ちなく補間でき、枠の光が滑らかに膨らんでは静まります。通知バッジやリアルタイムに更新されるステータスカードに向いています。

<main class="stage">
<span class="badge is-demo">新着通知</span>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 한 번 번졌다 가라앉는 시간
$easing: $ease-spring;
$color: $subject-blue; // 번짐 색
@include stage($stage-paper);
.stage { padding: clamp(6px, 2.4vw, 16px); }
// @property 로 길이를 등록해야 box-shadow 번짐 반경이 중간값을 거치며 부드럽게 바뀐다
@property --glow {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
.badge {
display: inline-block;
border: 2px solid $color;
border-radius: $r-pill;
padding: 12px 24px;
font-weight: 700; font-size: clamp(13px, 3.6vw, 15px); line-height: 1; font-family: inherit;
color: $stage-ink;
background: $stage-paper;
box-shadow: 0 0 var(--glow) 0 rgba($color, .55);
}
.badge.is-demo { animation: glow $duration $easing infinite; }
@keyframes glow {
0%, 100% { --glow: 0px; }
50% { --glow: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.badge.is-demo { animation: none; --glow: 8px; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
@property --glow {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
.badge {
display: inline-block;
border: 2px solid #2f6df6;
border-radius: 999px;
padding: 12px 24px;
font-weight: 700;
font-size: clamp(13px, 3.6vw, 15px);
line-height: 1;
font-family: inherit;
color: #17141a;
background: #ffffff;
box-shadow: 0 0 var(--glow) 0 rgba(47, 109, 246, 0.55);
}
.badge.is-demo {
animation: glow 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes glow {
0%, 100% {
--glow: 0px;
}
50% {
--glow: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.badge.is-demo {
animation: none;
--glow: 8px;
}
}import type { CSSProperties } from "react";
import styles from "./GlowPulseBadge.module.scss";
export type GlowPulseBadgeProps = {
label: string;
/** 번졌다 가라앉는 한 번의 시간 (기본 2000ms) */
duration?: number;
easing?: string;
/** 번짐 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function GlowPulseBadge({
label,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: GlowPulseBadgeProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return <span className={styles.badge} style={vars}>{label}</span>;
}04 · グローパルス枠 — 計測 累積面積 1.765% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
通知バッジに
@property --glow {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
.badge {
box-shadow: 0 0 var(--glow) 0 rgba($color, .55);
}
.badge.is-demo { animation: glow $duration $easing infinite; }
@keyframes glow {
0%, 100% { --glow: 0px; }
50% { --glow: 16px; }
}
05角のブラケット
四隅だけが開く仕組み
四隅の.frame__bracket--tlなどを角ごとに独立した要素として置き、ホバーや:focus-visibleでそれぞれの対角線方向へtranslateさせることで、カメラのフォーカスフレームのように四隅だけが開きます。画像ギャラリーで選んだ項目を示す枠に向いています。

<main class="stage">
<div class="frame is-demo" tabindex="0">
<span class="frame__bracket frame__bracket--tl" aria-hidden="true"></span>
<span class="frame__bracket frame__bracket--tr" aria-hidden="true"></span>
<span class="frame__bracket frame__bracket--bl" aria-hidden="true"></span>
<span class="frame__bracket frame__bracket--br" aria-hidden="true"></span>
<span class="frame__label">選択中</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 실제 호버 시 브래킷이 벌어지는 시간
$easing: $ease-pop;
$color: $subject-cream; // 브래킷 색
@include stage($stage-ink);
.stage { padding: clamp(6px, 2.4vw, 16px); }
.frame {
position: relative;
width: min(200px, 64vw);
aspect-ratio: 4 / 3;
border-radius: 4px;
background: linear-gradient(135deg, rgba($color, .16), rgba($color, .04));
display: grid;
place-items: center;
outline: none;
}
.frame__bracket {
position: absolute;
width: 22px;
height: 22px;
border: 3px solid $color;
transition: transform $duration $easing;
}
.frame__bracket--tl { top: -3px; left: -3px; border-right: 0; border-bottom: 0; }
.frame__bracket--tr { top: -3px; right: -3px; border-left: 0; border-bottom: 0; }
.frame__bracket--bl { bottom: -3px; left: -3px; border-right: 0; border-top: 0; }
.frame__bracket--br { bottom: -3px; right: -3px; border-left: 0; border-top: 0; }
.frame:hover .frame__bracket--tl, .frame:focus-visible .frame__bracket--tl { transform: translate(-6px, -6px); }
.frame:hover .frame__bracket--tr, .frame:focus-visible .frame__bracket--tr { transform: translate(6px, -6px); }
.frame:hover .frame__bracket--bl, .frame:focus-visible .frame__bracket--bl { transform: translate(-6px, 6px); }
.frame:hover .frame__bracket--br, .frame:focus-visible .frame__bracket--br { transform: translate(6px, 6px); }
// 데모 루프: 2초 안에서 벌어짐 → 유지 → 되돌아옴
.frame.is-demo .frame__bracket--tl { animation: open-tl $dur-loop $easing infinite; }
.frame.is-demo .frame__bracket--tr { animation: open-tr $dur-loop $easing infinite; }
.frame.is-demo .frame__bracket--bl { animation: open-bl $dur-loop $easing infinite; }
.frame.is-demo .frame__bracket--br { animation: open-br $dur-loop $easing infinite; }
@keyframes open-tl { 0%, 15% { transform: translate(0, 0); } 50%, 70% { transform: translate(-6px, -6px); } 100% { transform: translate(0, 0); } }
@keyframes open-tr { 0%, 15% { transform: translate(0, 0); } 50%, 70% { transform: translate(6px, -6px); } 100% { transform: translate(0, 0); } }
@keyframes open-bl { 0%, 15% { transform: translate(0, 0); } 50%, 70% { transform: translate(-6px, 6px); } 100% { transform: translate(0, 0); } }
@keyframes open-br { 0%, 15% { transform: translate(0, 0); } 50%, 70% { transform: translate(6px, 6px); } 100% { transform: translate(0, 0); } }
.frame__label { font-weight: 600; font-size: clamp(11px, 3vw, 12px); line-height: 1; font-family: inherit; color: rgba($color, .8); }
@media (prefers-reduced-motion: reduce) {
.frame.is-demo .frame__bracket { animation: none; }
}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 {
padding: clamp(6px, 2.4vw, 16px);
}
.frame {
position: relative;
width: min(200px, 64vw);
aspect-ratio: 4/3;
border-radius: 4px;
background: linear-gradient(135deg, rgba(255, 247, 230, 0.16), rgba(255, 247, 230, 0.04));
display: grid;
place-items: center;
outline: none;
}
.frame__bracket {
position: absolute;
width: 22px;
height: 22px;
border: 3px solid #fff7e6;
transition: transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.frame__bracket--tl {
top: -3px;
left: -3px;
border-right: 0;
border-bottom: 0;
}
.frame__bracket--tr {
top: -3px;
right: -3px;
border-left: 0;
border-bottom: 0;
}
.frame__bracket--bl {
bottom: -3px;
left: -3px;
border-right: 0;
border-top: 0;
}
.frame__bracket--br {
bottom: -3px;
right: -3px;
border-left: 0;
border-top: 0;
}
.frame:hover .frame__bracket--tl, .frame:focus-visible .frame__bracket--tl {
transform: translate(-6px, -6px);
}
.frame:hover .frame__bracket--tr, .frame:focus-visible .frame__bracket--tr {
transform: translate(6px, -6px);
}
.frame:hover .frame__bracket--bl, .frame:focus-visible .frame__bracket--bl {
transform: translate(-6px, 6px);
}
.frame:hover .frame__bracket--br, .frame:focus-visible .frame__bracket--br {
transform: translate(6px, 6px);
}
.frame.is-demo .frame__bracket--tl {
animation: open-tl 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.frame.is-demo .frame__bracket--tr {
animation: open-tr 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.frame.is-demo .frame__bracket--bl {
animation: open-bl 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.frame.is-demo .frame__bracket--br {
animation: open-br 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes open-tl {
0%, 15% {
transform: translate(0, 0);
}
50%, 70% {
transform: translate(-6px, -6px);
}
100% {
transform: translate(0, 0);
}
}
@keyframes open-tr {
0%, 15% {
transform: translate(0, 0);
}
50%, 70% {
transform: translate(6px, -6px);
}
100% {
transform: translate(0, 0);
}
}
@keyframes open-bl {
0%, 15% {
transform: translate(0, 0);
}
50%, 70% {
transform: translate(-6px, 6px);
}
100% {
transform: translate(0, 0);
}
}
@keyframes open-br {
0%, 15% {
transform: translate(0, 0);
}
50%, 70% {
transform: translate(6px, 6px);
}
100% {
transform: translate(0, 0);
}
}
.frame__label {
font-weight: 600;
font-size: clamp(11px, 3vw, 12px);
line-height: 1;
font-family: inherit;
color: rgba(255, 247, 230, 0.8);
}
@media (prefers-reduced-motion: reduce) {
.frame.is-demo .frame__bracket {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./CornerBracketsFrame.module.scss";
export type CornerBracketsFrameProps = {
label: string;
/** 브래킷이 벌어지는 시간 (기본 2000ms, 루프용) */
duration?: number;
easing?: string;
/** 브래킷 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function CornerBracketsFrame({
label,
duration = 2000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#fff7e6",
}: CornerBracketsFrameProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.frame} style={vars} tabIndex={0}>
<span className={`${styles.bracket} ${styles.tl}`} aria-hidden="true" />
<span className={`${styles.bracket} ${styles.tr}`} aria-hidden="true" />
<span className={`${styles.bracket} ${styles.bl}`} aria-hidden="true" />
<span className={`${styles.bracket} ${styles.br}`} aria-hidden="true" />
<span className={styles.label}>{label}</span>
</div>
);
}05 · 角のブラケット — 計測 累積面積 1.503% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
画像ギャラリーの選択枠に
.frame__bracket--tl { top: -3px; left: -3px; border-right: 0; border-bottom: 0; }
.frame__bracket--br { bottom: -3px; right: -3px; border-left: 0; border-top: 0; }
.frame:hover .frame__bracket--tl,
.frame:focus-visible .frame__bracket--tl { transform: translate(-6px, -6px); }
.frame:hover .frame__bracket--br,
.frame:focus-visible .frame__bracket--br { transform: translate(6px, 6px); }
.frame.is-demo .frame__bracket--tl { animation: open-tl $dur-loop $easing infinite; }
@keyframes open-tl {
0%, 15% { transform: translate(0, 0); }
50%, 70% { transform: translate(-6px, -6px); }
100% { transform: translate(0, 0); }
}
06下線が伸びる
中央から左右へ広がる下線
入力欄の下の.field__barをleft: 50%; right: 50%で中央に畳んでおき、フォーカス時に両方を0へ遷移させると中心から左右へ広がります。scaleXではなくleftとrightを使ったのは、別途transform-originを指定しなくても中央基準の伸びが得られるからです。

<main class="stage">
<div class="field is-demo">
<label class="field__label" for="email">メールアドレス</label>
<input class="field__input" id="email" type="email" placeholder="メール">
<span class="field__bar" aria-hidden="true"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 실제 포커스 시 밑줄이 벌어지는 시간
$easing: $ease-out;
$color: $subject-blue; // 밑줄 색
@include stage($stage-yellow);
.stage { padding: clamp(6px, 2.4vw, 16px); }
.field {
position: relative;
width: min(220px, 70vw);
}
.field__label {
display: block;
margin-bottom: 4px;
font-weight: 600; font-size: clamp(11px, 3vw, 12px); line-height: 1.3; font-family: inherit;
color: rgba($stage-ink, .7);
}
.field__input {
box-sizing: border-box;
width: 100%;
border: 0;
border-bottom: 2px solid rgba($stage-ink, .25);
background: transparent;
padding: 10px 2px;
font-weight: 500; font-size: clamp(13px, 3.6vw, 15px); line-height: 1.3; font-family: inherit;
color: $stage-ink;
}
.field__bar {
position: absolute;
left: 50%; right: 50%; bottom: -2px;
height: 2px;
background: $color;
transition: left $duration $easing, right $duration $easing;
}
.field__input:focus + .field__bar { left: 0; right: 0; }
// 데모 루프: 가운데서 시작 → 양쪽으로 채워짐 → 다시 가운데로
.field.is-demo .field__bar { animation: grow $dur-loop $easing infinite; }
@keyframes grow {
0%, 10% { left: 50%; right: 50%; }
40%, 60% { left: 0; right: 0; }
100% { left: 50%; right: 50%; }
}
@media (prefers-reduced-motion: reduce) {
.field__bar { transition: none; }
.field.is-demo .field__bar { animation: none; left: 0; right: 0; }
}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);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
.field {
position: relative;
width: min(220px, 70vw);
}
.field__label {
display: block;
margin-bottom: 4px;
font-weight: 600;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.3;
font-family: inherit;
color: rgba(23, 20, 26, 0.7);
}
.field__input {
box-sizing: border-box;
width: 100%;
border: 0;
border-bottom: 2px solid rgba(23, 20, 26, 0.25);
background: transparent;
padding: 10px 2px;
font-weight: 500;
font-size: clamp(13px, 3.6vw, 15px);
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.field__bar {
position: absolute;
left: 50%;
right: 50%;
bottom: -2px;
height: 2px;
background: #2f6df6;
transition: left 220ms ease-out, right 220ms ease-out;
}
.field__input:focus + .field__bar {
left: 0;
right: 0;
}
.field.is-demo .field__bar {
animation: grow 2s ease-out infinite;
}
@keyframes grow {
0%, 10% {
left: 50%;
right: 50%;
}
40%, 60% {
left: 0;
right: 0;
}
100% {
left: 50%;
right: 50%;
}
}
@media (prefers-reduced-motion: reduce) {
.field__bar {
transition: none;
}
.field.is-demo .field__bar {
animation: none;
left: 0;
right: 0;
}
}import type { CSSProperties } from "react";
import styles from "./UnderlineGrowField.module.scss";
export type UnderlineGrowFieldProps = {
label: string;
placeholder: string;
/** 실제 포커스 시 밑줄이 벌어지는 시간 (기본 220ms) */
duration?: number;
easing?: string;
/** 밑줄 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function UnderlineGrowField({
label,
placeholder,
duration = 220,
easing = "ease-out",
color = "#2f6df6",
}: UnderlineGrowFieldProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const id = "field-" + label.replace(/\s+/g, "-").toLowerCase();
return (
<div className={styles.field} style={vars}>
<label className={styles.label} htmlFor={id}>{label}</label>
<input className={styles.input} id={id} type="text" placeholder={placeholder} />
<span className={styles.bar} aria-hidden="true" />
</div>
);
}06 · 下線が伸びる — 計測 累積面積 0.306% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
scaleXを使わなかった理由
.field__bar {
position: absolute;
left: 50%; right: 50%; bottom: -2px;
height: 2px;
background: $color;
transition: left $duration $easing, right $duration $easing;
}
.field__input:focus + .field__bar { left: 0; right: 0; }
.field.is-demo .field__bar { animation: grow $dur-loop $easing infinite; }
@keyframes grow {
0%, 10% { left: 50%; right: 50%; }
40%, 60% { left: 0; right: 0; }
100% { left: 50%; right: 50%; }
}
07二重リング枠
二重目のリングが波紋になるまで
box-shadowで作った内側の固定リングの外に、.btn::afterで二重目のリングを重ねておき、ホバーと:focus-visibleでscaleを1.3まで広げながらopacityを0から1へ上げて波紋のように見せます。ボタンのホバー強調や通知アイコンの強調に向いています。

<main class="stage">
<button class="btn is-demo" type="button">購読する</button>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 실제 호버 시 바깥 링이 퍼지는 시간
$easing: $ease-out;
$color: $subject-cream; // 링 색
@include stage($stage-orange);
.stage { padding: clamp(6px, 2.4vw, 16px); }
.btn {
position: relative;
border: 0;
border-radius: $r-pill;
padding: 14px 30px;
font-weight: 700; font-size: clamp(13px, 3.6vw, 15px); line-height: 1; font-family: inherit;
color: $stage-ink;
background: $color;
cursor: pointer;
box-shadow: 0 0 0 3px rgba($color, .9); // 안쪽 고정 링
}
.btn::after { // 바깥 링 — 평소엔 숨어 있다
content: "";
position: absolute;
inset: -3px;
border-radius: inherit;
border: 2px solid $color;
opacity: 0;
transform: scale(1);
transition: transform $duration $easing, opacity $duration $easing;
}
.btn:hover::after, .btn:focus-visible::after { opacity: 1; transform: scale(1.3); }
.btn.is-demo::after { animation: ring-pulse $dur-loop $easing infinite; }
@keyframes ring-pulse {
0%, 15% { transform: scale(1); opacity: .9; }
70% { transform: scale(1.35); opacity: 0; }
100% { transform: scale(1.35); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.btn::after { transition: none; }
.btn.is-demo::after { animation: none; opacity: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
.btn {
position: relative;
border: 0;
border-radius: 999px;
padding: 14px 30px;
font-weight: 700;
font-size: clamp(13px, 3.6vw, 15px);
line-height: 1;
font-family: inherit;
color: #17141a;
background: #fff7e6;
cursor: pointer;
box-shadow: 0 0 0 3px rgba(255, 247, 230, 0.9);
}
.btn::after {
content: "";
position: absolute;
inset: -3px;
border-radius: inherit;
border: 2px solid #fff7e6;
opacity: 0;
transform: scale(1);
transition: transform 300ms ease-out, opacity 300ms ease-out;
}
.btn:hover::after, .btn:focus-visible::after {
opacity: 1;
transform: scale(1.3);
}
.btn.is-demo::after {
animation: ring-pulse 2s ease-out infinite;
}
@keyframes ring-pulse {
0%, 15% {
transform: scale(1);
opacity: 0.9;
}
70% {
transform: scale(1.35);
opacity: 0;
}
100% {
transform: scale(1.35);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.btn::after {
transition: none;
}
.btn.is-demo::after {
animation: none;
opacity: 0;
}
}import type { CSSProperties } from "react";
import styles from "./DoubleRingButton.module.scss";
export type DoubleRingButtonProps = {
label: string;
/** 실제 호버 시 바깥 링이 퍼지는 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 링 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function DoubleRingButton({
label,
duration = 300,
easing = "ease-out",
color = "#fff7e6",
}: DoubleRingButtonProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<button className={styles.btn} style={vars} type="button">
{label}
</button>
);
}07 · 二重リング枠 — 計測 累積面積 2.954% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
ボタンのホバー強調に
.btn { box-shadow: 0 0 0 3px rgba($color, .9); } // 内側の固定リング
.btn::after {
content: "";
position: absolute;
inset: -3px;
border: 2px solid $color;
opacity: 0;
transform: scale(1);
transition: transform $duration $easing, opacity $duration $easing;
}
.btn:hover::after,
.btn:focus-visible::after { opacity: 1; transform: scale(1.3); }
08虹色の枠
「css 枠線 グラデーション」を止めたまま色だけ回す
border-image-sourceに固定の虹色linear-gradientを設定し、filter: hue-rotateの値だけを0degから360degへ回すことで、グラデーション自体は動かさずに枠の色だけがその場で巡回します。イベントバナーやお祝いカードの枠に向いています。

<main class="stage">
<span class="badge is-demo">イベント</span>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 색이 한 바퀴 도는 시간
$easing: linear;
$color: $stage-orange; // (참고용, 실제 색은 아래 무지개 그라데이션)
@include stage($stage-paper);
.stage { padding: clamp(6px, 2.4vw, 16px); }
.badge {
display: inline-block;
border: 4px solid;
border-image-slice: 1;
// 토큰 4색으로 이은 그라데이션 — 팔레트 밖 hex 를 새로 쓰지 않는다 (blog-design)
border-image-source: linear-gradient(90deg, $error, $stage-orange, $stage-yellow, $subject-blue, $error);
border-radius: 4px;
padding: 14px 22px;
font-weight: 700; font-size: clamp(13px, 3.6vw, 15px); line-height: 1; font-family: inherit;
color: $stage-ink;
background: $stage-paper;
}
// 그라데이션 각도는 그대로 두고 filter: hue-rotate 만 돌려 색만 자리에서 순환시킨다
.badge.is-demo { animation: hue $duration $easing infinite; }
@keyframes hue { to { filter: hue-rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.badge.is-demo { animation: none; filter: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
padding: clamp(6px, 2.4vw, 16px);
}
.badge {
display: inline-block;
border: 4px solid;
border-image-slice: 1;
border-image-source: linear-gradient(90deg, #c2361a, #ff4d1f, #ffd23f, #2f6df6, #c2361a);
border-radius: 4px;
padding: 14px 22px;
font-weight: 700;
font-size: clamp(13px, 3.6vw, 15px);
line-height: 1;
font-family: inherit;
color: #17141a;
background: #ffffff;
}
.badge.is-demo {
animation: hue 2s linear infinite;
}
@keyframes hue {
to {
filter: hue-rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.badge.is-demo {
animation: none;
filter: none;
}
}import type { CSSProperties } from "react";
import styles from "./RainbowBorderBadge.module.scss";
export type RainbowBorderBadgeProps = {
label: string;
/** 색이 한 바퀴 도는 시간 (기본 2000ms) */
duration?: number;
};
type Vars = CSSProperties & { "--duration"?: string };
export default function RainbowBorderBadge({ label, duration = 2000 }: RainbowBorderBadgeProps) {
const vars: Vars = { "--duration": `${duration}ms` };
return <span className={styles.badge} style={vars}>{label}</span>;
}08 · 虹色の枠 — 計測 累積面積 0.761% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
イベントバナーの枠に
.badge {
border: 4px solid;
border-image-slice: 1;
border-image-source: linear-gradient(90deg, $error, $stage-orange, $stage-yellow, $subject-blue, $error);
}
.badge.is-demo { animation: hue $duration $easing infinite; }
@keyframes hue { to { filter: hue-rotate(360deg); } }
09フォーカスリングアニメーション
outlineを透明から広げる
.field__inputのoutlineを平常時は透明にしておき、:focus-visibleのときだけoutline-colorとoutline-offset(0→4px)を同時に遷移させることで、リングが跳ねるように外へ広がって落ち着きます。マウスのクリックには反応せず、キーボードでのフォーカスだけに反応します。

<main class="stage">
<div class="field">
<label class="field__label" for="name">名前</label>
<input class="field__input is-demo" id="name" type="text" placeholder="名前">
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 실제 포커스 시 링이 벌어지는 시간
$easing: $ease-out;
$color: $subject-blue; // 링 색
@include stage($stage-ink);
.stage { padding: clamp(6px, 2.4vw, 16px); }
.field { width: min(220px, 70vw); }
.field__label {
display: block;
margin-bottom: 6px;
font-weight: 600; font-size: clamp(11px, 3vw, 12px); line-height: 1.3; font-family: inherit;
color: rgba($subject-cream, .7);
}
.field__input {
box-sizing: border-box;
width: 100%;
border: 2px solid rgba($subject-cream, .3);
border-radius: $r-control;
padding: 12px 14px;
font-weight: 500; font-size: clamp(13px, 3.6vw, 15px); line-height: 1.3; font-family: inherit;
color: $subject-cream;
background: transparent;
outline: 2px solid transparent;
outline-offset: 0;
transition: outline-color $duration $easing, outline-offset $duration $easing;
}
.field__input:focus-visible { outline-color: $color; outline-offset: 4px; }
// 데모 루프: 링이 붙어 있다가 튀어나오듯 벌어지고 다시 붙는다
.field__input.is-demo { animation: ring $dur-loop $easing infinite; }
@keyframes ring {
0%, 10% { outline-offset: 0; outline-color: rgba($color, .9); }
45%, 65% { outline-offset: 4px; outline-color: rgba($color, .2); }
100% { outline-offset: 0; outline-color: rgba($color, .9); }
}
@media (prefers-reduced-motion: reduce) {
.field__input { transition: none; }
.field__input.is-demo { animation: none; outline-color: rgba($color, .9); }
}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 {
padding: clamp(6px, 2.4vw, 16px);
}
.field {
width: min(220px, 70vw);
}
.field__label {
display: block;
margin-bottom: 6px;
font-weight: 600;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.3;
font-family: inherit;
color: rgba(255, 247, 230, 0.7);
}
.field__input {
box-sizing: border-box;
width: 100%;
border: 2px solid rgba(255, 247, 230, 0.3);
border-radius: 12px;
padding: 12px 14px;
font-weight: 500;
font-size: clamp(13px, 3.6vw, 15px);
line-height: 1.3;
font-family: inherit;
color: #fff7e6;
background: transparent;
outline: 2px solid transparent;
outline-offset: 0;
transition: outline-color 220ms ease-out, outline-offset 220ms ease-out;
}
.field__input:focus-visible {
outline-color: #2f6df6;
outline-offset: 4px;
}
.field__input.is-demo {
animation: ring 2s ease-out infinite;
}
@keyframes ring {
0%, 10% {
outline-offset: 0;
outline-color: rgba(47, 109, 246, 0.9);
}
45%, 65% {
outline-offset: 4px;
outline-color: rgba(47, 109, 246, 0.2);
}
100% {
outline-offset: 0;
outline-color: rgba(47, 109, 246, 0.9);
}
}
@media (prefers-reduced-motion: reduce) {
.field__input {
transition: none;
}
.field__input.is-demo {
animation: none;
outline-color: rgba(47, 109, 246, 0.9);
}
}import type { CSSProperties } from "react";
import styles from "./FocusRingField.module.scss";
export type FocusRingFieldProps = {
label: string;
placeholder: string;
/** 실제 포커스 시 링이 벌어지는 시간 (기본 220ms) */
duration?: number;
easing?: string;
/** 링 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function FocusRingField({
label,
placeholder,
duration = 220,
easing = "ease-out",
color = "#2f6df6",
}: FocusRingFieldProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const id = "focus-" + label.replace(/\s+/g, "-").toLowerCase();
return (
<div className={styles.field} style={vars}>
<label className={styles.label} htmlFor={id}>{label}</label>
<input className={styles.input} id={id} type="text" placeholder={placeholder} />
</div>
);
}09 · フォーカスリングアニメーション — 計測 累積面積 2.264% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
キーボードだけに反応する
.field__input {
outline: 2px solid transparent;
outline-offset: 0;
transition: outline-color $duration $easing, outline-offset $duration $easing;
}
.field__input:focus-visible {
outline-color: $color;
outline-offset: 4px;
}
どこで崩れるか — 落とし穴
mask-compositeは二つ並べて書く
9つの中で01はmask-compositeを使って枠の形だけをくり抜いています。この値を接頭辞なしで書くとChrome・Edge 120以上、Safari 15.4以上、Firefox 53以上でしか効きません(MDNのmask-composite対応表)。それより古いChromeは-webkit-mask-composite: xorしか読まないため、標準のmask-composite: excludeだけを書くと最新ブラウザでの確認では問題なく見えても、古い環境では枠がくり抜かれずただ塗りつぶされた四角として表示されます。両方の宣言を並べて書いたのはそのためです。
08の虹色の枠のように色そのものが主役の枠をもっと柔らかい色調に置き換えたいときは、パステル デザインのLPキットにあるセクションごとの配色をそのまま値として差し替えれば済みます。
property登録がないと補間できない
もう一つ実際に組んでいて気づいたのは04の@propertyです。登録なしでカスタムプロパティだけをアニメーションさせると、ブラウザは開始値と終了値の間を補間できず、box-shadowのぼかし半径が中間の値を経ずに一段飛びで切り替わりました。<length>として型を宣言してからは0pxから16pxまでを滑らかに経由するようになり、この登録はChrome・Edge 85以上、Safari 16.4以上、Firefox 128以上で対応しています(MDNの@property)。ここまでの直しを反映した9ファイルを開くパスワードは4vruzyhzです。下のカードからzipを受け取ってください。
filterは中身ごと再描画する
もう一つは08のfilterです。filterは要素の中身をまるごと合成し直す性質があるため、バッジの中に文字やアイコンを増やすとhue-rotateが一巡するたびにその領域全体が再描画されます。虹色の枠は小さなバッジのような要素にとどめ、カード全体や大きなコンテナには付けないほうが安全です。
アクセシビリティ
動きを減らすと何が止まるか
prefers-reduced-motion: reduceをすべての項目に用意しています。01・03・08は色や破線が流れる背景だけを止めて、模様はそのままの位置で静止します。02は線が描かれきった状態(stroke-dashoffset: 0)で止まり、04は--glowを8pxの中間値に固定して膨らみっぱなしにも消えっぱなしにもしません。05はループ用のanimationだけを外し、実際のホバーやフォーカスのtransitionはそのまま残るので、操作すれば角は今までどおり開きます。06はleft・rightを0で固定して線が伸びきった状態を見せ、07はループ演出の外側リングだけを消して内側の固定リングだけを残します。09は例外で、transitionを切ってもキーボードフォーカス時にoutline-colorが変わる仕組み自体は生かしてあり、常時ループする見本用の状態も薄いリングが点いたままにしてあります。フォーカスリングは装飾ではなく「今どこにいるか」を伝える印だからです。
@media (prefers-reduced-motion: reduce) {
.frame.is-demo::before,
.frame.is-demo .frame__edge,
.badge.is-demo,
.frame.is-demo .frame__bracket,
.field.is-demo .field__bar,
.btn.is-demo::after { animation: none; }
}
次に読む
このほか、枠線ではないCSSアニメーションはCSSカテゴリーに、サイトの紹介は紹介ページにまとめました。
FAQ
02のSVG枠線描画で、pathLengthを指定しないとどうなりますか?
pathLength="100"を外すと、stroke-dasharrayとstroke-dashoffsetの数値はSVGの実際のピクセル単位の周長を基準にすることになります。カードの大きさを変えるたびに周長も変わるので、100のままでは線が描き終わる前に止まったり、描き終わったあとも余分な線が残ったりします。カードの幅を固定しないなら、pathLengthで単位をそろえておくほうが安全です。
「css ボーダー アニメーション」をmask-compositeの代わりにborder-imageで作れますか?
一部だけです。08のように色だけの固定グラデーションはborder-imageで組めますが、01のように背景を流し続けるアニメーションには対応する位置のプロパティがborder-imageにないため、mask-compositeかSVGを使うほうが向いています。09のフォーカスリングのように入力中の欄だけを示す用途であれば、CSS 入力フォーム フォーカスアニメーションの九つがその場面だけを扱っています。
zipのReact版でも同じ動きになりますか?
はい、zipのreact/フォルダの各コンポーネントはdivやinput要素にこれまでと対応するクラス名を付けるだけで、色や時間はCSS変数としてpropsから渡されます。02もstroke-dashoffsetの値をJSXが計算するのではなく、durationとeasingをCSS変数として渡すだけで、ループするアニメーション自体は他の項目と同じ仕組みで動いています。









