9 CSS Border Animations — One Line, Copy-Paste
A css border animation moves the edge of a card, button, or input instead of its fill, so it can signal loading, focus, or selection without shifting anything
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Diagonal highlight sweep border
- 02 SVG border draw
- 03 Dashed line march
- 04 Glow pulse border
- 05 Corner brackets
- 06 Underline grow
- 07 Double ring border
- 08 Rainbow border
- 09 Animated focus ring
These nine css border animation effects run in the order a border tends to earn attention in a real interface. 01, 03, and 04 stay in motion on their own, standing in for something still processing, while 02 draws itself in once, confirming a card loaded or a form went through. 05 and 06 react to a person doing something — hovering a tile, focusing a field. 07 is a one-shot hover flourish, 08 marks a moment worth noticing, and 09 closes on the one border that exists purely for keyboard users. Play with the grid above, then pull whichever item earns its spot out of the zip below. When the signal belongs to the press itself rather than the edge around it, the CSS button click feedback effects own that beat, and the plain motion primitives sitting underneath 01's sweep are laid out one at a time in the CSS keyframe animations set.
01Diagonal highlight sweep border
A striped gradient cut to the border
A ring cut to the exact border shape carries a diagonal striped gradient, and sliding its background-position on a loop makes the whole frame read as lit and flowing at once rather than one hotspot circling the edge.

<main class="stage">
<div class="frame is-demo">
<input class="frame__input" type="text" placeholder="Type a message" 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 · Diagonal highlight sweep border — Measured area 1.167% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where constant motion fits
It fits an input field an AI answer is still filling, or a premium card frame that wants constant motion without pulling focus to one spot.
.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;
mask-composite: exclude;
}
.frame.is-demo::before { animation: sweep $dur-loop linear infinite; }
@keyframes sweep { to { background-position: 220px 0; } }
02SVG border draw
Why pathLength counts to 100
The card's outline is an SVG rect given pathLength="100", so stroke-dashoffset always counts down from 100 to 0 no matter the rect's actual perimeter in pixels.

<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">Card issued</h3>
<p class="card__meta">Identity verified</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 border draw — Measured area 1.148% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
Drawn in like a pen
Looping that offset from 100 to 0 and back draws the border in like a pen retracing the same line, which fits a card confirming it loaded or a form confirming it submitted.
.card__rect {
fill: none;
stroke: $color;
stroke-width: 3;
stroke-dasharray: 100;
stroke-dashoffset: 100;
}
.card.is-demo .card__rect { animation: draw-loop $dur-loop $ease-out infinite; }
@keyframes draw-loop {
0%, 5% { stroke-dashoffset: 100; }
35%, 60% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: -100; }
}
03Dashed line march
Four edges, four axes
Each of the four edges carries its own dashed gradient background, and sliding background-position along that specific edge's own axis — not one shared direction for all four — makes the dashes appear to march clockwise around the whole box together.

<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">Uploading</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 · Dashed line march — Measured area 1.1% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Standing in for a percentage
It reads well on a card mid-upload, standing in for a percentage nobody wants to watch tick by.
.frame__edge--top, .frame__edge--bottom {
background-image: repeating-linear-gradient(90deg, $color 0 8px, transparent 8px 16px);
}
.frame__edge--left, .frame__edge--right {
background-image: repeating-linear-gradient(180deg, $color 0 8px, transparent 8px 16px);
}
.frame.is-demo .frame__edge--top { animation: run-x $dur-loop linear infinite; }
.frame.is-demo .frame__edge--bottom { animation: run-x-rev $dur-loop linear infinite; }
@keyframes run-x { to { background-position: 32px 0; } }
@keyframes run-x-rev { to { background-position: -32px 0; } }
04Glow pulse border
Registering --glow as a length
A --glow custom property, registered through @property as a <length>, animates between 0px and 16px, and once the browser knows its type it moves the box-shadow blur radius through every step in between rather than jumping straight from one extreme to the other.

<main class="stage">
<span class="badge is-demo">New alert</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 · Glow pulse border — Measured area 1.765% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
A badge that breathes
That steady middle is what makes it worth using on a badge or status card that needs to breathe on its own.
@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 $dur-loop $ease-spring infinite; }
@keyframes glow { 0%, 100% { --glow: 0px; } 50% { --glow: 16px; } }
05Corner brackets
Four brackets on four diagonals
Four independent L-shaped brackets sit at the corners, each pushed outward along its own diagonal by a plain transform: translate on hover or keyboard focus.

<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">Selected</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 · Corner brackets — Measured area 1.503% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
A camera focus frame
Opening all four at once reads as a camera focus frame snapping shut on a subject, which is why it fits the tile a person just picked in an image gallery.
.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:hover .frame__bracket--tl,
.frame:focus-visible .frame__bracket--tl { transform: translate(-6px, -6px); }
.frame.is-demo .frame__bracket--tl { animation: open-tl $dur-loop $ease-pop infinite; }
@keyframes open-tl {
0%, 15% { transform: translate(0, 0); }
50%, 70% { transform: translate(-6px, -6px); }
100% { transform: translate(0, 0); }
}
06Underline grow
Filling outward from the center
The bar under the field starts collapsed at the center with left: 50%; right: 50%, and focusing the input transitions both properties to 0 so the bar fills outward from the middle instead of sliding in from one side.

<main class="stage">
<div class="field is-demo">
<label class="field__label" for="email">Email address</label>
<input class="field__input" id="email" type="email" placeholder="Email">
<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 · Underline grow — Measured area 0.306% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why not scaleX
Using left and right instead of scaleX keeps that centered fill without a separate transform-origin line anywhere in the rule.
.field__bar {
position: absolute;
left: 50%; right: 50%; bottom: -2px;
background: $color;
transition: left $dur-quick $ease-out, right $dur-quick $ease-out;
}
.field__input:focus + .field__bar { left: 0; right: 0; }
07Double ring border
Why a css border animation on hover needs two rings
A fixed inner ring comes from a plain box-shadow on the button itself, while a second ring built on ::after stays invisible at rest and only scales up while fading on hover or focus, reading as one ripple leaving the button behind.

<main class="stage">
<button class="btn is-demo" type="button">Subscribe</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 · Double ring border — Measured area 2.954% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
More presence than a color swap
It works as a hover cue for a button that needs more presence than a color swap alone gives it.
.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 $dur-base $ease-out, opacity $dur-base $ease-out;
}
.btn:hover::after, .btn:focus-visible::after { opacity: 1; transform: scale(1.3); }
08Rainbow border
Colors cycling in place
In this css border animation gradient the border-image-source never moves, and the only property animating is filter: hue-rotate, turning from 0deg to 360deg so the colors cycle in place instead of sliding along the edge the way item 01's sweep does.

<main class="stage">
<span class="badge is-demo">Event</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 · Rainbow border — Measured area 0.761% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where a festive frame belongs
It suits an event banner or a small celebration badge, where the whole frame should feel festive without competing with the sweep pattern above.
.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 $dur-loop linear infinite; }
@keyframes hue { to { filter: hue-rotate(360deg); } }
09Animated focus ring
Outline offset, not border width
outline sits fully transparent at rest, and the moment keyboard focus lands, outline-color fades in while outline-offset transitions from 0 to 4px, so the ring pops outward and settles instead of just snapping into view.

<main class="stage">
<div class="field">
<label class="field__label" for="name">Name</label>
<input class="field__input is-demo" id="name" type="text" placeholder="Name">
</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 · Animated focus ring — Measured area 2.264% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
Keyboard focus only
Because the rule only fires on :focus-visible, a mouse click never triggers it — only Tab, arrow keys, or any other keyboard-driven focus does.
.field__input {
outline: 2px solid transparent;
outline-offset: 0;
transition: outline-color $dur-quick $ease-out, outline-offset $dur-quick $ease-out;
}
.field__input:focus-visible { outline-color: $color; outline-offset: 4px; }
Where it breaks — the trap
Only one of the nine carries a hard requirement, and it isn't the loudest. Item 08's rainbow is a plain gradient that ports to any palette at all — the gentler one in the pastel website design kit drops in by swapping hex values and nothing else. Item 04 is the one that breaks outright.
Why the @property registration carries item 04
Item 04's glow only reads as a pulse because @property tells the browser --glow is a <length> before the box-shadow rule ever touches it. Drop that registration and animate the bare custom property instead, and a browser has no declared type to interpolate between — the blur radius jumps straight from 0px to 16px with no steps in between, since an unregistered custom property is treated as a plain string, not a number a browser can tween — the same way an unregistered custom color would refuse to fade smoothly between two hex values. MDN's @property reference covers exactly which browser versions accept the registration and which fall back to the string behavior instead, which matters most for anyone still shipping this badge to an older Safari or Firefox build. The archive keeps that registration in item 04's own file, and the whole set unlocks with 4vruzyhz, copied straight from this sentence — nothing added, nothing dropped.
Two mask declarations, not one
Item 01 leans on a second, older trap: its ring only stays cut out because two mask declarations sit side by side, -webkit-mask-composite: xor for WebKit's own compositing keyword and mask-composite: exclude for the standard property that replaced it. The two don't share values — xor isn't a legal value for the unprefixed property at all, per MDN's mask-composite page — and support for the unprefixed form still lags well behind the -webkit- line it stands next to, so dropping either declaration quietly reverts the ring to a solid filled box on whichever engine only reads the one that's missing. Item 08's filter: hue-rotate has its own cost worth flagging separately: filter repaints every child inside the element it's applied to, so a badge with an icon and label stays cheap, but stretching the same rule over a large card with lots of nested content repaints far more than the border alone needs.
Accessibility
How the nine answer reduced motion
prefers-reduced-motion: reduce sits on all nine, and most of them respond the same way: 01, 03, 04, and 08 freeze their looping background or glow at a single resting frame instead of continuing to cycle. 02 skips straight to its finished state — the border fully drawn — rather than animating there. 05 only turns its own auto-loop off; the brackets rest closed until hovered or focused, when the corner transition still opens them with full easing. 07's ripple simply stops firing on hover, leaving only the fixed inner ring behind. 06 and 09 are the two exceptions worth calling out: their transitions still respond to focus, just without easing, because a focus indicator that vanished under reduced motion would leave a keyboard user with no way to see where they landed at all.
@media (prefers-reduced-motion: reduce) {
.frame.is-demo::before, .badge.is-demo, .card.is-demo .card__rect { animation: none; }
}
Where the rest of it lives
The rest of this site's motion work lives on the CSS category hub, and what this project does and doesn't sell is on the about page.
FAQ
Is a css border animation the same thing as an outline animation?
Not quite. outline draws outside the box and never affects layout, which is why item 09 leans on it for a focus ring that has to appear and disappear without nudging anything. The other eight animate an actual border, a box-shadow, a border-image, or a mask cut to the border's shape, and several of those do take up layout space unless they're pulled out with position: absolute the way items 01, 03, 05, and 07 are here.
Do any of these nine need JavaScript to run?
Mostly not. Eight of the nine drive their motion from CSS alone — :hover, :focus, :focus-visible, or a plain infinite @keyframes loop for .is-demo. Item 02 is the exception: its one-shot draw-in needs JavaScript to add the .is-drawn class once the card loads, though its own .is-demo loop above still plays with CSS alone. The react/ folder just wraps each one in a component so the loop can be toggled from props.
Which css border animation loop is safest to leave running on a page nobody is watching?
06 and 09, since both sit completely still until someone actually focuses the field — nothing moves until that happens. 01, 03, 04, and 08 are the four built to loop on their own, so keep at most one of those visible per screen; several looping borders competing for attention at once mostly tell a visitor where not to look. For a field's own focus and typing states specifically, the CSS input focus animations take what 06 and 09 start and run it nine different ways.









