03お腹をゆする笑い
豆のフレンドが目を三日月に折り、笑いで体が左右に傾きお腹が揺れます。押すたびに笑いが3段階まで大きくなり、最後は体全体が折り曲がります。
使いどころコメント投稿成功のバッジ
rotatescaleXanimation-duration

<main class="stage">
<div class="bn is-demo" id="bn" data-level="0">
<button class="bn__hit" type="button" aria-label="豆の友だちを笑わせる">
<span class="bn__scene" aria-hidden="true">
<span class="bn__shadow"></span>
<span class="bn__bean" id="bnBean">
<span class="bn__eye bn__eye--l"></span>
<span class="bn__eye bn__eye--r"></span>
<span class="bn__mouth"><span class="bn__tongue"></span></span>
<span class="bn__arm bn__arm--l"></span>
<span class="bn__arm bn__arm--r"></span>
</span>
</span>
</button>
<p class="bn__level" id="bnLevel" aria-hidden="true">笑いレベル0</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 배 흔들기 웃음 — 눈을 반달로 접고 웃느라 몸이 기울고 배가 출렁이는 콩알 친구.
// 기울기의 원점은 발바닥(땅에 닿은 붙는 지점)이다 — 중심에 두면 발이 미끄러져 떠 보인다.
// 웃음 단계(data-level)는 기울기 진폭 --tilt 로만 커진다. 3단계에서는 몸이 앞으로 폭 접힌다.
// 정적 경로(미리보기 아님)에서도 기본 웃음 루프는 항상 돈다 — 누르면 진폭이 커질 뿐이다.
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-lilac;
@include stage($stage-orange);
.bn { display: grid; justify-items: center; gap: $sp-2; }
.bn__hit { border: 0; padding: 0; background: none; cursor: pointer; -webkit-tap-highlight-color: transparent; }
.bn__hit:focus-visible { outline: 2px solid $subject-ink; outline-offset: 4px; border-radius: $r-card; }
.bn__scene {
position: relative; display: block; box-sizing: border-box;
width: clamp(140px, 44vw, 196px); height: clamp(120px, 38vw, 168px);
}
.bn__shadow {
position: absolute; bottom: 0; left: 50%; width: 58%; height: 12px;
background: rgba(23, 20, 26, .2); border-radius: 50%;
transform: translateX(-50%);
}
// 콩알 몸 — 세로로 길쭉한 알갱이. 기울기 진폭은 --tilt 변수 하나
.bn__bean {
--tilt: 4deg;
position: absolute; bottom: 4px; left: 50%; width: 52%; aspect-ratio: 1 / 1.18;
background: $color; border: 3px solid $subject-ink;
border-radius: 50% 50% 46% 46% / 42% 42% 58% 58%;
transform: translateX(-50%);
transform-origin: 50% 100%; // 붙는 지점: 발바닥 — 몸이 기울어도 발은 제자리
}
// 팔 — 웃으며 몸을 감싼다
.bn__arm {
position: absolute; top: 46%; width: 26%; height: 10%;
background: $color; border: 3px solid $subject-ink; border-radius: $r-pill;
}
.bn__arm--l { left: -12%; transform: rotate(38deg); }
.bn__arm--r { right: -12%; transform: rotate(-38deg); }
// 반달 눈 — 웃어서 감긴 호. border-top 만 있는 원호다
.bn__eye {
position: absolute; top: 30%; width: 18%; height: 10%;
border-top: 4px solid $subject-ink; border-radius: 50%;
}
.bn__eye--l { left: 22%; }
.bn__eye--r { right: 22%; }
// 벌린 입 — 안쪽 혀까지. 웃음에 맞춰 세로로 벌어졌다 닫힌다
.bn__mouth {
position: absolute; top: 46%; left: 50%; width: 30%; aspect-ratio: 1 / .8;
background: $subject-ink; border-radius: 50%;
transform: translateX(-50%); overflow: hidden;
}
.bn__tongue {
position: absolute; bottom: -22%; left: 50%; width: 74%; aspect-ratio: 1 / .6;
background: $subject-pink; border-radius: 50%;
transform: translateX(-50%);
}
// 단계 표시 — 주황 무대 위 잉크 알약
.bn__level {
margin: 0; padding: 0 $sp-3; background: $subject-ink; color: $stage-yellow;
border-radius: $r-pill; font-weight: 700; font-size: 12px; line-height: 20px;
}
// ── 기본 웃음 루프(정적 경로에서도 항상 돈다). 진폭은 --tilt 하나뿐이라 단계가 곧 진폭
.bn__bean {
animation-name: bnTilt;
animation-duration: $duration;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
.bn__mouth {
animation-name: bnMouth;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes bnTilt {
0%, 100% { transform: translateX(-50%) rotate(calc(var(--tilt) * -1)); }
50% { transform: translateX(-50%) rotate(var(--tilt)); }
}
@keyframes bnMouth {
0%, 100% { transform: translateX(-50%) scale(1, .8); }
50% { transform: translateX(-50%) scale(1, 1.2); }
}
// ── 단계: 진폭이 커지다가 3단계에서 몸이 앞으로 폭 접힌다(일회성)
.bn[data-level="1"] .bn__bean { --tilt: 8deg; }
.bn[data-level="2"] .bn__bean { --tilt: 13deg; }
.bn[data-level="3"] .bn__bean {
--tilt: 4deg;
animation: bnFold 700ms cubic-bezier(.2, 1.4, .4, 1) 1 forwards;
}
@keyframes bnFold {
0% { transform: translateX(-50%) rotate(0deg) scale(1, 1); }
40% { transform: translateX(-50%) rotate(16deg) scale(1.06, .82); }
70% { transform: translateX(-50%) rotate(20deg) scale(1.1, .68); }
100% { transform: translateX(-50%) rotate(19deg) scale(1.08, .7); }
}
// ── 격자 미리보기 루프(2초): 같은 루프 안에서 단계가 커지다 접히고 돌아온다(90% 안에 복귀)
.bn.is-demo .bn__bean {
animation-name: bnLaughShow;
animation-duration: $duration;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes bnLaughShow {
0%, 8% { transform: translateX(-50%) rotate(-3deg) scaleX(1); }
16% { transform: translateX(-50%) rotate(3deg) scaleX(1.04); }
24% { transform: translateX(-50%) rotate(-3deg) scaleX(1); }
32% { transform: translateX(-50%) rotate(5deg) scaleX(1.06); }
40% { transform: translateX(-50%) rotate(-5deg) scaleX(1); }
48% { transform: translateX(-50%) rotate(5deg) scaleX(1.06); }
56% { transform: translateX(-50%) rotate(-5deg) scaleX(1); }
64% { transform: translateX(-50%) rotate(15deg) scale(1.06, .8); }
72%, 84% { transform: translateX(-50%) rotate(19deg) scale(1.08, .7); }
90%, 100% { transform: translateX(-50%) rotate(0deg) scale(1, 1); }
}
// ── 좁은 화면
@media (max-width: 340px) {
.bn { gap: $sp-1; }
.bn__level { font-size: 11px; line-height: 16px; }
.bn__bean { border-width: 2px; }
}
@media (prefers-reduced-motion: reduce) {
// 웃임 루프를 끊면 눈을 반달로 접고 선 채 웃고 있는 모습이 남는다
.bn__bean,
.bn__mouth,
.bn.is-demo .bn__bean {
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);
}
.bn {
display: grid;
justify-items: center;
gap: 8px;
}
.bn__hit {
border: 0;
padding: 0;
background: none;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.bn__hit:focus-visible {
outline: 2px solid #17141a;
outline-offset: 4px;
border-radius: 16px;
}
.bn__scene {
position: relative;
display: block;
box-sizing: border-box;
width: clamp(140px, 44vw, 196px);
height: clamp(120px, 38vw, 168px);
}
.bn__shadow {
position: absolute;
bottom: 0;
left: 50%;
width: 58%;
height: 12px;
background: rgba(23, 20, 26, 0.2);
border-radius: 50%;
transform: translateX(-50%);
}
.bn__bean {
--tilt: 4deg;
position: absolute;
bottom: 4px;
left: 50%;
width: 52%;
aspect-ratio: 1/1.18;
background: #b9a0ff;
border: 3px solid #17141a;
border-radius: 50% 50% 46% 46%/42% 42% 58% 58%;
transform: translateX(-50%);
transform-origin: 50% 100%;
}
.bn__arm {
position: absolute;
top: 46%;
width: 26%;
height: 10%;
background: #b9a0ff;
border: 3px solid #17141a;
border-radius: 999px;
}
.bn__arm--l {
left: -12%;
transform: rotate(38deg);
}
.bn__arm--r {
right: -12%;
transform: rotate(-38deg);
}
.bn__eye {
position: absolute;
top: 30%;
width: 18%;
height: 10%;
border-top: 4px solid #17141a;
border-radius: 50%;
}
.bn__eye--l {
left: 22%;
}
.bn__eye--r {
right: 22%;
}
.bn__mouth {
position: absolute;
top: 46%;
left: 50%;
width: 30%;
aspect-ratio: 1/0.8;
background: #17141a;
border-radius: 50%;
transform: translateX(-50%);
overflow: hidden;
}
.bn__tongue {
position: absolute;
bottom: -22%;
left: 50%;
width: 74%;
aspect-ratio: 1/0.6;
background: #ff9ebb;
border-radius: 50%;
transform: translateX(-50%);
}
.bn__level {
margin: 0;
padding: 0 12px;
background: #17141a;
color: #ffd23f;
border-radius: 999px;
font-weight: 700;
font-size: 12px;
line-height: 20px;
}
.bn__bean {
animation-name: bnTilt;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
.bn__mouth {
animation-name: bnMouth;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes bnTilt {
0%, 100% {
transform: translateX(-50%) rotate(calc(var(--tilt) * -1));
}
50% {
transform: translateX(-50%) rotate(var(--tilt));
}
}
@keyframes bnMouth {
0%, 100% {
transform: translateX(-50%) scale(1, 0.8);
}
50% {
transform: translateX(-50%) scale(1, 1.2);
}
}
.bn[data-level="1"] .bn__bean {
--tilt: 8deg;
}
.bn[data-level="2"] .bn__bean {
--tilt: 13deg;
}
.bn[data-level="3"] .bn__bean {
--tilt: 4deg;
animation: bnFold 700ms cubic-bezier(0.2, 1.4, 0.4, 1) 1 forwards;
}
@keyframes bnFold {
0% {
transform: translateX(-50%) rotate(0deg) scale(1, 1);
}
40% {
transform: translateX(-50%) rotate(16deg) scale(1.06, 0.82);
}
70% {
transform: translateX(-50%) rotate(20deg) scale(1.1, 0.68);
}
100% {
transform: translateX(-50%) rotate(19deg) scale(1.08, 0.7);
}
}
.bn.is-demo .bn__bean {
animation-name: bnLaughShow;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes bnLaughShow {
0%, 8% {
transform: translateX(-50%) rotate(-3deg) scaleX(1);
}
16% {
transform: translateX(-50%) rotate(3deg) scaleX(1.04);
}
24% {
transform: translateX(-50%) rotate(-3deg) scaleX(1);
}
32% {
transform: translateX(-50%) rotate(5deg) scaleX(1.06);
}
40% {
transform: translateX(-50%) rotate(-5deg) scaleX(1);
}
48% {
transform: translateX(-50%) rotate(5deg) scaleX(1.06);
}
56% {
transform: translateX(-50%) rotate(-5deg) scaleX(1);
}
64% {
transform: translateX(-50%) rotate(15deg) scale(1.06, 0.8);
}
72%, 84% {
transform: translateX(-50%) rotate(19deg) scale(1.08, 0.7);
}
90%, 100% {
transform: translateX(-50%) rotate(0deg) scale(1, 1);
}
}
@media (max-width: 340px) {
.bn {
gap: 4px;
}
.bn__level {
font-size: 11px;
line-height: 16px;
}
.bn__bean {
border-width: 2px;
}
}
@media (prefers-reduced-motion: reduce) {
.bn__bean,
.bn__mouth,
.bn.is-demo .bn__bean {
animation: none !important;
}
}var bn = document.getElementById('bn');
var level = 0;
var labels = ['笑いレベル0', '笑いレベル1', '笑いレベル2', '笑いレベル3 — 大爆笑'];
// 누를 때마다 웃음 단계가 0→1→2→3 까지 커지고, もう一度 누르면 처음으로 돌아간다
document.querySelector('.bn__hit').addEventListener('click', function () {
bn.classList.remove('is-demo');
level = (level + 1) % 4;
bn.dataset.level = String(level);
document.getElementById('bnLevel').textContent = labels[level];
});
bn.addEventListener('pointerdown', function () { bn.classList.remove('is-demo'); });// CuteBellyLaughBean — vanilla 판(run/075/demo/03-cute-belly-laugh-bean) 의 React 포트.
// 누를 때마다 웃음 단계가 0→1→2→3 까지 커지고 다시 누르면 처음으로 돌아간다(data-level).
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CuteBellyLaughBean.module.scss";
export type CuteBellyLaughBeanProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const LABELS = ["웃음 0단계", "웃음 1단계", "웃음 2단계", "웃음 3단계 — 폭 접힘"];
export default function CuteBellyLaughBean(props: CuteBellyLaughBeanProps) {
const duration = props.duration ?? 2000;
const easing = props.easing ?? "cubic-bezier(.2, 1.4, .4, 1)";
const color = props.color ?? "#b9a0ff";
const [level, setLevel] = useState<number>(0);
const [preview, setPreview] = useState<boolean>(true);
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
return (
<main className={styles.stage}>
<div
className={styles.bn + (preview ? " " + styles.isDemo : "")}
style={vars}
data-level={level}
>
<button
className={styles.hit}
type="button"
aria-label="콩알 친구 웃기기"
onClick={() => setLevel((level + 1) % 4)}
onPointerDown={() => setPreview(false)}
>
<span className={styles.scene} aria-hidden="true">
<span className={styles.shadow}></span>
<span className={styles.bean} key={"fold" + level}>
<span className={styles.eye + " " + styles.eyeL}></span>
<span className={styles.eye + " " + styles.eyeR}></span>
<span className={styles.mouth}><span className={styles.tongue}></span></span>
<span className={styles.arm + " " + styles.armL}></span>
<span className={styles.arm + " " + styles.armR}></span>
</span>
</span>
</button>
<p className={styles.level} aria-hidden="true">{LABELS[level]}</p>
</div>
</main>
);
}03 お腹をゆする笑い — 計測 累積面積 8.074% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · css キャラクター アニメーション 9選 — かわいいキャラを動かすCSS
このデモが属する記事
css キャラクター アニメーション 9選 — かわいいキャラを動かすCSScss キャラクター アニメーションとは、描いたキャラクターを画像ファイルなしでコードだけでまばたきさせたり跳ねさせたりする作りのことです。ハート目の顔からクッキーを分ける友だちまで9体を図形で組み、クリックするとキャラがすぐ反応するという この記事のデモをすべて見る


