06Status icon spinner-to-check/cross morph
Of three stacked icons on the right of the field, a spinner rotates, then hands off via opacity crossfade to a mint check, then to a coral cross.
Best forUsername-check-in-progress indicators, per-field validation icons
rotateopacity 크로스페이드svg 3겹
<main class="stage">
<div class="fv6 is-demo">
<span class="fv6__field">
<input class="fv6__input" type="text" value="godrich01" readonly>
<span class="fv6__icon">
<svg class="fv6__spinner" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><circle cx="12" cy="12" r="9" stroke-dasharray="42 14"/></svg>
<svg class="fv6__check" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="4,13 9,18 20,6"/></svg>
<svg class="fv6__cross" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
</span>
</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 필드 오른쪽 아이콘이 스피너(rotate) → 체크(opacity 크로스페이드) → 엑스(opacity 크로스페이드) 순으로 모핑한다
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-yellow);
.fv6 { width: min(240px, 86vw); }
.fv6__field { position: relative; display: block; }
.fv6__input {
box-sizing: border-box; width: 100%;
padding: $sp-2 $sp-8 $sp-2 $sp-3; border-radius: $r-sm;
border: 2px solid $color; background: rgba(23, 20, 26, .06);
color: $color; font-weight: 400; font-size: 14px; line-height: 1; font-family: inherit;
}
.fv6__icon { position: absolute; right: $sp-3; top: 0; bottom: 0; display: flex; align-items: center; width: 18px; }
.fv6__icon svg { position: absolute; inset: 0; opacity: 0; }
.fv6__spinner { color: $color; }
.fv6__check { color: $subject-mint; }
.fv6__cross { color: $error; }
.fv6__icon::after {
content: ""; position: absolute; left: 50%; top: 50%; width: 26px; height: 26px;
margin: -13px 0 0 -13px; border-radius: 50%; opacity: 0; pointer-events: none;
}
@keyframes fv6-spin { to { transform: rotate(360deg); } }
@keyframes fv6-spinner-show { 0%, 32% { opacity: 1; } 38%, 100% { opacity: 0; } }
@keyframes fv6-check-show { 0%, 32% { opacity: 0; } 40%, 62% { opacity: 1; } 70%, 100% { opacity: 0; } }
@keyframes fv6-cross-show { 0%, 62% { opacity: 0; } 70%, 96% { opacity: 1; } 100% { opacity: 0; } }
// 한 겹: 상태 등장 순간의 감쇠 shake + 글로우 surge (체크 40% 미세·mint / 엑스 70% 뚜렷·error)
@keyframes fv6-shake {
0%, 40% { transform: translateX(0); }
42% { transform: translateX(-2px); } 44% { transform: translateX(2px); }
46% { transform: translateX(-1.4px); } 48% { transform: translateX(1.2px); }
50% { transform: translateX(-.7px); }
52%, 70% { transform: translateX(0); }
72% { transform: translateX(-3px); } 74% { transform: translateX(3px); }
76% { transform: translateX(-2.2px); } 78% { transform: translateX(2px); }
80% { transform: translateX(-1.2px); } 82% { transform: translateX(.8px); }
84%, 100% { transform: translateX(0); }
}
@keyframes fv6-surge {
0%, 38% { opacity: 0; box-shadow: 0 0 0 0 rgba($subject-mint, 0); }
40% { opacity: .9; box-shadow: 0 0 0 4px rgba($subject-mint, .55); }
46% { opacity: .7; box-shadow: 0 0 0 8px rgba($subject-mint, .3); }
54% { opacity: 0; box-shadow: 0 0 0 12px rgba($subject-mint, 0); }
68% { opacity: 0; box-shadow: 0 0 0 0 rgba($error, 0); }
70% { opacity: .9; box-shadow: 0 0 0 4px rgba($error, .5); }
78% { opacity: .6; box-shadow: 0 0 0 9px rgba($error, .25); }
88% { opacity: 0; box-shadow: 0 0 0 13px rgba($error, 0); }
100% { opacity: 0; box-shadow: 0 0 0 0 rgba($subject-mint, 0); }
}
.fv6.is-demo .fv6__spinner { animation: fv6-spin $dur-base linear infinite, fv6-spinner-show $duration $easing infinite; }
.fv6.is-demo .fv6__check { animation: fv6-check-show $duration $easing infinite; }
.fv6.is-demo .fv6__cross { animation: fv6-cross-show $duration $easing infinite; }
.fv6.is-demo .fv6__input { animation: fv6-shake $duration $easing infinite; }
.fv6.is-demo .fv6__icon::after { animation: fv6-surge $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.fv6.is-demo .fv6__spinner,
.fv6.is-demo .fv6__check,
.fv6.is-demo .fv6__cross { animation: none !important; opacity: 0; }
.fv6.is-demo .fv6__check { opacity: 1; }
.fv6.is-demo .fv6__input,
.fv6.is-demo .fv6__icon::after { animation: none !important; }
.fv6.is-demo .fv6__icon::after { opacity: 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);
}
.fv6 {
width: min(240px, 86vw);
}
.fv6__field {
position: relative;
display: block;
}
.fv6__input {
box-sizing: border-box;
width: 100%;
padding: 8px 32px 8px 12px;
border-radius: 8px;
border: 2px solid #17141a;
background: rgba(23, 20, 26, 0.06);
color: #17141a;
font-weight: 400;
font-size: 14px;
line-height: 1;
font-family: inherit;
}
.fv6__icon {
position: absolute;
right: 12px;
top: 0;
bottom: 0;
display: flex;
align-items: center;
width: 18px;
}
.fv6__icon svg {
position: absolute;
inset: 0;
opacity: 0;
}
.fv6__spinner {
color: #17141a;
}
.fv6__check {
color: #4cd4a6;
}
.fv6__cross {
color: #c2361a;
}
.fv6__icon::after {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 26px;
height: 26px;
margin: -13px 0 0 -13px;
border-radius: 50%;
opacity: 0;
pointer-events: none;
}
@keyframes fv6-spin {
to {
transform: rotate(360deg);
}
}
@keyframes fv6-spinner-show {
0%, 32% {
opacity: 1;
}
38%, 100% {
opacity: 0;
}
}
@keyframes fv6-check-show {
0%, 32% {
opacity: 0;
}
40%, 62% {
opacity: 1;
}
70%, 100% {
opacity: 0;
}
}
@keyframes fv6-cross-show {
0%, 62% {
opacity: 0;
}
70%, 96% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@keyframes fv6-shake {
0%, 40% {
transform: translateX(0);
}
42% {
transform: translateX(-2px);
}
44% {
transform: translateX(2px);
}
46% {
transform: translateX(-1.4px);
}
48% {
transform: translateX(1.2px);
}
50% {
transform: translateX(-0.7px);
}
52%, 70% {
transform: translateX(0);
}
72% {
transform: translateX(-3px);
}
74% {
transform: translateX(3px);
}
76% {
transform: translateX(-2.2px);
}
78% {
transform: translateX(2px);
}
80% {
transform: translateX(-1.2px);
}
82% {
transform: translateX(0.8px);
}
84%, 100% {
transform: translateX(0);
}
}
@keyframes fv6-surge {
0%, 38% {
opacity: 0;
box-shadow: 0 0 0 0 rgba(76, 212, 166, 0);
}
40% {
opacity: 0.9;
box-shadow: 0 0 0 4px rgba(76, 212, 166, 0.55);
}
46% {
opacity: 0.7;
box-shadow: 0 0 0 8px rgba(76, 212, 166, 0.3);
}
54% {
opacity: 0;
box-shadow: 0 0 0 12px rgba(76, 212, 166, 0);
}
68% {
opacity: 0;
box-shadow: 0 0 0 0 rgba(194, 54, 26, 0);
}
70% {
opacity: 0.9;
box-shadow: 0 0 0 4px rgba(194, 54, 26, 0.5);
}
78% {
opacity: 0.6;
box-shadow: 0 0 0 9px rgba(194, 54, 26, 0.25);
}
88% {
opacity: 0;
box-shadow: 0 0 0 13px rgba(194, 54, 26, 0);
}
100% {
opacity: 0;
box-shadow: 0 0 0 0 rgba(76, 212, 166, 0);
}
}
.fv6.is-demo .fv6__spinner {
animation: fv6-spin 300ms linear infinite, fv6-spinner-show 2s ease-out infinite;
}
.fv6.is-demo .fv6__check {
animation: fv6-check-show 2s ease-out infinite;
}
.fv6.is-demo .fv6__cross {
animation: fv6-cross-show 2s ease-out infinite;
}
.fv6.is-demo .fv6__input {
animation: fv6-shake 2s ease-out infinite;
}
.fv6.is-demo .fv6__icon::after {
animation: fv6-surge 2s ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.fv6.is-demo .fv6__spinner,
.fv6.is-demo .fv6__check,
.fv6.is-demo .fv6__cross {
animation: none !important;
opacity: 0;
}
.fv6.is-demo .fv6__check {
opacity: 1;
}
.fv6.is-demo .fv6__input,
.fv6.is-demo .fv6__icon::after {
animation: none !important;
}
.fv6.is-demo .fv6__icon::after {
opacity: 0;
}
}import type { CSSProperties } from "react";
import styles from "./IconMorphCheckCross.module.scss";
export type IconMorphCheckCrossProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function IconMorphCheckCross({ duration = 2000, easing = "ease-out", color = "#17141a" }: IconMorphCheckCrossProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.fv6} style={vars}>
<span className={styles.field}>
<input className={styles.input} type="text" defaultValue="godrich01" readOnly />
<span className={styles.icon}>
<svg className={styles.spinner} viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round"><circle cx="12" cy="12" r="9" strokeDasharray="42 14" /></svg>
<svg className={styles.check} viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round"><polyline points="4,13 9,18 20,6" /></svg>
<svg className={styles.cross} viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round"><line x1={6} y1={6} x2={18} y2={18} /><line x1={18} y1={6} x2={6} y2={18} /></svg>
</span>
</span>
</div>
);
}06 Status icon spinner-to-check/cross morph — Measured area 1.374% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · 9 Form Validation CSS Animations You Can Paste
The post this demo lives in
9 Form Validation CSS Animations You Can PasteForm validation CSS animations are small signals — color, icon, glow — that show a field is right or wrong, no shaking r See all demos in this post
