07Tail-wag waiting dog
A dog holds its spot, tail sweeping and ears swaying. Every three presses the wag speeds up a step; on the fifth the eyes turn to crescents and one heart rises.
Best forBuddy on an upload-wait screen
rotatetransform-originanimation-timing-function

<main class="stage">
<div class="dg is-demo" id="dg" data-speed="0">
<button class="dg__hit" type="button" aria-label="Pet the puppy">
<span class="dg__scene" aria-hidden="true">
<span class="dg__shadow"></span>
<span class="dg__tail" id="dgTail"></span>
<span class="dg__body"></span>
<span class="dg__leg dg__leg--1"></span>
<span class="dg__leg dg__leg--2"></span>
<span class="dg__leg dg__leg--3"></span>
<span class="dg__leg dg__leg--4"></span>
<span class="dg__head">
<span class="dg__ear"></span>
<span class="dg__eyeOpen dg__eyeOpen--1"></span>
<span class="dg__eyeOpen dg__eyeOpen--2"></span>
<span class="dg__happy dg__happy--1"></span>
<span class="dg__happy dg__happy--2"></span>
<span class="dg__snout"><span class="dg__nose"></span></span>
</span>
<span class="dg__heart" id="dgHeart"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></svg></span>
</span>
</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 꼬리 흔드는 강아지 대기 — 옆모습 강아지가 자리를 지키며 꼬리를 흔들고 귀가 살랑거린다.
// 꼬리의 원점은 몸에 닿은 꼬리 뿌리(붙는 지점), 귀의 원점은 머리에 닿은 귀 위쪽이다.
// 꼬리 속도는 data-speed 단계(0.5s → 0.36s → 0.26s). 0.5s 만 2s 에 나누어떨어지고 0.36·0.26 은 아니다(5.56·7.69회) — 미리보기 루프 컷은 별도 키프레임이 맡는다. (주석 정정 2026-09-13)
// 다섯 번째 쓰다듬음에는 눈이 반달(하트 눈)으로 컷 교체되고 하트가 한 번 떠오른다.
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-cream;
@include stage($stage-orange);
.dg { display: grid; place-items: center; }
.dg__hit { border: 0; padding: 0; background: none; cursor: pointer; -webkit-tap-highlight-color: transparent; }
.dg__hit:focus-visible { outline: 2px solid $subject-ink; outline-offset: 4px; border-radius: $r-card; }
.dg__scene {
position: relative; display: block; box-sizing: border-box;
width: clamp(220px, 66vw, 300px); aspect-ratio: 16 / 9;
}
.dg__shadow {
position: absolute; bottom: 0; left: 50%; width: 64%; height: 12px;
background: rgba(23, 20, 26, .2); border-radius: 50%;
transform: translateX(-50%);
}
// 몸통 — 왼쪽을 보고 서 있는 알약
.dg__body {
position: absolute; bottom: 12px; left: 24%; width: 52%; height: 34%;
background: $color; border: 3px solid $subject-ink; border-radius: $r-pill;
}
.dg__leg {
position: absolute; bottom: 0; width: 7%; height: 16%;
background: $color; border: 3px solid $subject-ink; border-radius: $r-xs;
}
.dg__leg--1 { left: 30%; } .dg__leg--2 { left: 40%; }
.dg__leg--3 { left: 58%; } .dg__leg--4 { left: 68%; }
// 머리 — 몸 왼쪽 위. 주인을 보고 살짝 기울었다
.dg__head {
position: absolute; bottom: 26%; left: 4%; width: 34%; aspect-ratio: 1;
background: $color; border: 3px solid $subject-ink; border-radius: 50%;
transform: rotate(-6deg);
transform-origin: 80% 90%; // 붙는 지점: 목이 닿은 오른쪽 아래 — 고개를 젖힐 때 목이 고정
animation-name: dgHeadTilt;
animation-duration: 1s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes dgHeadTilt {
0%, 100% { transform: rotate(-6deg); }
50% { transform: rotate(-2deg); }
}
// 귀 — 머리에 붙어 늘어진 넓은 귀. 원점은 붙은 위쪽
.dg__ear {
position: absolute; top: -4%; left: 6%; width: 34%; aspect-ratio: 1 / 1.5;
background: $subject-pink; border: 3px solid $subject-ink; border-radius: 50% 50% 46% 46%;
transform-origin: 50% 6%;
animation-name: dgEarSway;
animation-duration: 1s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes dgEarSway {
0%, 100% { transform: rotate(-6deg); }
50% { transform: rotate(7deg); }
}
// 꼬리 — 몸 오른쪽 끝에 붙어 위로 선 알약. 원점은 몸에 닿은 아래쪽 뿌리
.dg__tail {
position: absolute; bottom: 38%; right: 16%; width: 6%; height: 24%;
background: $subject-pink; border: 3px solid $subject-ink; border-radius: $r-pill;
transform-origin: 50% 100%;
animation-name: dgWag;
animation-duration: .5s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes dgWag {
0%, 100% { transform: rotate(-22deg); }
50% { transform: rotate(22deg); }
}
// 쓰다듬을수록 꼬리가 빨라진다 — 0.36s·0.26s 는 2s 의 약수라 루프 박자가 어긋나지 않는다
.dg[data-speed="2"] .dg__tail { animation-duration: .36s; }
.dg[data-speed="3"] .dg__tail { animation-duration: .26s; }
// 눈 두 벌(둥근 눈·반달 눈)을 겹치고 컷 교체 — 다섯 번째 쓰다듬음에 반달이 된다
.dg__eyeOpen, .dg__happy { position: absolute; top: 34%; width: 13%; aspect-ratio: 1; }
.dg__eyeOpen { background: $subject-ink; border-radius: 50%; }
.dg__eyeOpen--1 { left: 20%; } .dg__eyeOpen--2 { left: 56%; }
.dg__happy { border-top: 4px solid $subject-ink; border-radius: 50%; visibility: hidden; }
.dg__happy--1 { left: 20%; } .dg__happy--2 { left: 56%; }
.dg--love .dg__eyeOpen { visibility: hidden; }
.dg--love .dg__happy { visibility: visible; }
.dg__snout {
position: absolute; top: 52%; left: 34%; width: 34%; aspect-ratio: 1 / .8;
background: $stage-paper; border: 3px solid $subject-ink; border-radius: 50%;
}
.dg__nose {
position: absolute; top: 10%; left: 50%; width: 26%; aspect-ratio: 1;
background: $subject-ink; border-radius: 50%;
transform: translateX(-50%);
}
// 하트 — 다섯 번째 쓰다듬음에 머리 위로 한 번 떠오른다(일회성). 크기로 줄어들 뿐 페이드는 쓰지 않는다
.dg__heart {
position: absolute; top: 6%; left: 12%; width: 12%; aspect-ratio: 1;
color: $stage-yellow; transform: scale(0);
}
.dg__heart svg { display: block; width: 100%; height: 100%; }
.dg__heart--fly { animation: dgHeartRise 900ms cubic-bezier(.2, 1.2, .4, 1) 1 forwards; }
@keyframes dgHeartRise {
0% { transform: translateY(6px) scale(.2); }
30% { transform: translateY(-4px) scale(1.05); }
70% { transform: translateY(-22px) scale(.85); }
100% { transform: translateY(-34px) scale(.2); }
}
// ── 격자 미리보기 루프(2초): 꼬리·귀·고개는 항상 돌고, 30~56% 에 한 번 깜빡이며
// 62~88% 에는 하트가 떠오르는 모습도 프레임에 잡힌다
.dg.is-demo .dg__heart {
animation-name: dgHeartShow;
animation-duration: $duration;
animation-timing-function: cubic-bezier(.2, 1.2, .4, 1);
animation-iteration-count: infinite;
}
@keyframes dgHeartShow {
0%, 61% { transform: translateY(6px) scale(0); }
68% { transform: translateY(-6px) scale(1.05); }
80% { transform: translateY(-24px) scale(.8); }
88%, 100% { transform: translateY(-34px) scale(0); }
}
// ── 좁은 화면
@media (max-width: 340px) {
.dg__body, .dg__head, .dg__leg, .dg__tail, .dg__ear, .dg__snout { border-width: 2px; }
}
@media (prefers-reduced-motion: reduce) {
// 꼬리·귀·고개 흔들림을 끊면 앉아서 기다리는 강아지가 남는다 — 쓰다듬으면 눈만 반달로 바뀐다
.dg__tail,
.dg__ear,
.dg__head,
.dg.is-demo .dg__heart,
.dg__heart--fly {
animation: none !important;
}
}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);
}
.dg {
display: grid;
place-items: center;
}
.dg__hit {
border: 0;
padding: 0;
background: none;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.dg__hit:focus-visible {
outline: 2px solid #17141a;
outline-offset: 4px;
border-radius: 16px;
}
.dg__scene {
position: relative;
display: block;
box-sizing: border-box;
width: clamp(220px, 66vw, 300px);
aspect-ratio: 16/9;
}
.dg__shadow {
position: absolute;
bottom: 0;
left: 50%;
width: 64%;
height: 12px;
background: rgba(23, 20, 26, 0.2);
border-radius: 50%;
transform: translateX(-50%);
}
.dg__body {
position: absolute;
bottom: 12px;
left: 24%;
width: 52%;
height: 34%;
background: #fff7e6;
border: 3px solid #17141a;
border-radius: 999px;
}
.dg__leg {
position: absolute;
bottom: 0;
width: 7%;
height: 16%;
background: #fff7e6;
border: 3px solid #17141a;
border-radius: 4px;
}
.dg__leg--1 {
left: 30%;
}
.dg__leg--2 {
left: 40%;
}
.dg__leg--3 {
left: 58%;
}
.dg__leg--4 {
left: 68%;
}
.dg__head {
position: absolute;
bottom: 26%;
left: 4%;
width: 34%;
aspect-ratio: 1;
background: #fff7e6;
border: 3px solid #17141a;
border-radius: 50%;
transform: rotate(-6deg);
transform-origin: 80% 90%;
animation-name: dgHeadTilt;
animation-duration: 1s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes dgHeadTilt {
0%, 100% {
transform: rotate(-6deg);
}
50% {
transform: rotate(-2deg);
}
}
.dg__ear {
position: absolute;
top: -4%;
left: 6%;
width: 34%;
aspect-ratio: 1/1.5;
background: #ff9ebb;
border: 3px solid #17141a;
border-radius: 50% 50% 46% 46%;
transform-origin: 50% 6%;
animation-name: dgEarSway;
animation-duration: 1s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes dgEarSway {
0%, 100% {
transform: rotate(-6deg);
}
50% {
transform: rotate(7deg);
}
}
.dg__tail {
position: absolute;
bottom: 38%;
right: 16%;
width: 6%;
height: 24%;
background: #ff9ebb;
border: 3px solid #17141a;
border-radius: 999px;
transform-origin: 50% 100%;
animation-name: dgWag;
animation-duration: 0.5s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes dgWag {
0%, 100% {
transform: rotate(-22deg);
}
50% {
transform: rotate(22deg);
}
}
.dg[data-speed="2"] .dg__tail {
animation-duration: 0.36s;
}
.dg[data-speed="3"] .dg__tail {
animation-duration: 0.26s;
}
.dg__eyeOpen, .dg__happy {
position: absolute;
top: 34%;
width: 13%;
aspect-ratio: 1;
}
.dg__eyeOpen {
background: #17141a;
border-radius: 50%;
}
.dg__eyeOpen--1 {
left: 20%;
}
.dg__eyeOpen--2 {
left: 56%;
}
.dg__happy {
border-top: 4px solid #17141a;
border-radius: 50%;
visibility: hidden;
}
.dg__happy--1 {
left: 20%;
}
.dg__happy--2 {
left: 56%;
}
.dg--love .dg__eyeOpen {
visibility: hidden;
}
.dg--love .dg__happy {
visibility: visible;
}
.dg__snout {
position: absolute;
top: 52%;
left: 34%;
width: 34%;
aspect-ratio: 1/0.8;
background: #ffffff;
border: 3px solid #17141a;
border-radius: 50%;
}
.dg__nose {
position: absolute;
top: 10%;
left: 50%;
width: 26%;
aspect-ratio: 1;
background: #17141a;
border-radius: 50%;
transform: translateX(-50%);
}
.dg__heart {
position: absolute;
top: 6%;
left: 12%;
width: 12%;
aspect-ratio: 1;
color: #ffd23f;
transform: scale(0);
}
.dg__heart svg {
display: block;
width: 100%;
height: 100%;
}
.dg__heart--fly {
animation: dgHeartRise 900ms cubic-bezier(0.2, 1.2, 0.4, 1) 1 forwards;
}
@keyframes dgHeartRise {
0% {
transform: translateY(6px) scale(0.2);
}
30% {
transform: translateY(-4px) scale(1.05);
}
70% {
transform: translateY(-22px) scale(0.85);
}
100% {
transform: translateY(-34px) scale(0.2);
}
}
.dg.is-demo .dg__heart {
animation-name: dgHeartShow;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.2, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes dgHeartShow {
0%, 61% {
transform: translateY(6px) scale(0);
}
68% {
transform: translateY(-6px) scale(1.05);
}
80% {
transform: translateY(-24px) scale(0.8);
}
88%, 100% {
transform: translateY(-34px) scale(0);
}
}
@media (max-width: 340px) {
.dg__body, .dg__head, .dg__leg, .dg__tail, .dg__ear, .dg__snout {
border-width: 2px;
}
}
@media (prefers-reduced-motion: reduce) {
.dg__tail,
.dg__ear,
.dg__head,
.dg.is-demo .dg__heart,
.dg__heart--fly {
animation: none !important;
}
}var dg = document.getElementById('dg');
var heart = document.getElementById('dgHeart');
var pets = 0;
// 세 번씩 쓰다듬을 때마다 꼬리가 빨라지고(data-speed), 다섯 번째에는 눈이 반달이 되며 하트가 한 번 뜬다
document.querySelector('.dg__hit').addEventListener('click', function () {
dg.classList.remove('is-demo');
pets = pets + 1;
dg.dataset.speed = String(Math.min(1 + Math.floor(pets / 3), 3));
dg.classList.toggle('dg--love', pets === 5);
if (pets === 5) {
heart.classList.remove('dg__heart--fly');
void heart.offsetWidth;
heart.classList.add('dg__heart--fly');
}
});
dg.addEventListener('pointerdown', function () { dg.classList.remove('is-demo'); });// CuteTailWagWait — vanilla 판(run/075/demo/07-cute-tail-wag-wait) 의 React 포트.
// 세 번씩 쓰다듬을 때마다 꼬리가 빨라지고(data-speed), 다섯 번째에는 반달 눈과 하트가 뜬다.
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CuteTailWagWait.module.scss";
export type CuteTailWagWaitProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const HEART_SVG = (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></svg>
);
export default function CuteTailWagWait(props: CuteTailWagWaitProps) {
const duration = props.duration ?? 2000;
const easing = props.easing ?? "cubic-bezier(.2, 1.4, .4, 1)";
const color = props.color ?? "#fff7e6";
const [pets, setPets] = useState<number>(0);
const [preview, setPreview] = useState<boolean>(true);
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
const speed = Math.min(1 + Math.floor(pets / 3), 3);
return (
<main className={styles.stage}>
<div
className={styles.dg + (preview ? " " + styles.isDemo : "")}
style={vars}
data-speed={speed}
>
<button
className={styles.hit}
type="button"
aria-label="강아지 쓰다듬기"
onClick={() => setPets(pets + 1)}
onPointerDown={() => setPreview(false)}
>
<span className={styles.scene} aria-hidden="true">
<span className={styles.shadow}></span>
<span className={styles.tail} key={"wag" + pets}></span>
<span className={styles.body}></span>
<span className={styles.leg + " " + styles.leg1}></span>
<span className={styles.leg + " " + styles.leg2}></span>
<span className={styles.leg + " " + styles.leg3}></span>
<span className={styles.leg + " " + styles.leg4}></span>
<span className={styles.head + (pets === 5 ? " " + styles.love : "")}>
<span className={styles.ear}></span>
<span className={styles.eyeOpen + " " + styles.eyeOpen1}></span>
<span className={styles.eyeOpen + " " + styles.eyeOpen2}></span>
<span className={styles.happy + " " + styles.happy1}></span>
<span className={styles.happy + " " + styles.happy2}></span>
<span className={styles.snout}><span className={styles.nose}></span></span>
</span>
<span className={styles.heart + (pets === 5 ? " " + styles.heartRise : "")} key={"heart" + pets}>{HEART_SVG}</span>
</span>
</button>
</div>
</main>
);
}07 Tail-wag waiting dog — Measured area 4.646% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Cute CSS Animations: 9 Kawaii Character Demos
The post this demo lives in
Cute CSS Animations: 9 Kawaii Character DemosCute CSS animations are characters that blink, bounce, and blush from nothing but shapes and keyframes — no image files See all demos in this post


