AI先生のロボットキャラクター
第1章 - セクション5

ぷるぷる弾む流体シェイプボタン

SVG(SMIL)版とCSS(Web Animations API)版の2種類で、ゼリーのように弾むボタンを作って仕組みを学びます。

男子生徒のアイコン

クリックすると、ゼリーみたいにぷるぷる弾むボタンって作れますか?

AI先生のアイコン

作れます。形そのものを変形させる方法と、見た目の形をCSSで揺らす方法の2パターンが代表的です。

女子生徒のアイコン

どっちを選べばいいか、どうやって判断するんですか?

AI先生のアイコン

目的で決めるのが早いです。形をしっかりモーフィングしたいならSVG、軽く実装したいならCSS寄りが向いています。

作成した流体シェイプボタン

どちらも共通して、初期状態 → ホバー → 押下 → 離すという「状態」を分けて考えると、動きが整理しやすくなります。

1. 流体シェイプボタン(SVG + SMIL版)

SVGとSMILアニメーションで、形状(パス)をなめらかにモーフィングさせます。

HTML
<button class="jelly-btn" type="button" aria-label="ゼリーボタン" id="jelly" data-state="idle">
    <svg viewBox="0 0 200 200" role="img" aria-hidden="true">
        <defs>
            <linearGradient id="mainGradient" x1="0%" y1="0%" x2="100%" y2="100%">
                <stop offset="0%" stop-color="#32de84"></stop>
                <stop offset="100%" stop-color="#f6f930"></stop>
            </linearGradient>
            <radialGradient id="jellyLight" cx="35%" cy="30%" r="60%">
                <stop offset="0%" stop-color="rgba(255,255,255,.65)"></stop>
                <stop offset="35%" stop-color="rgba(255,255,255,.25)"></stop>
                <stop offset="70%" stop-color="rgba(255,255,255,.05)"></stop>
                <stop offset="100%" stop-color="rgba(255,255,255,0)"></stop>
            </radialGradient>
            <radialGradient id="jellyLightPressed" cx="65%" cy="70%" r="65%">
                <stop offset="0%" stop-color="rgba(255,255,255,.25)"></stop>
                <stop offset="45%" stop-color="rgba(255,255,255,.08)"></stop>
                <stop offset="100%" stop-color="rgba(255,255,255,0)"></stop>
            </radialGradient>
            <filter id="innerShadow" x="-30%" y="-30%" width="160%" height="160%">
                <feOffset dx="-2" dy="-2"></feOffset>
                <feGaussianBlur stdDeviation="4" result="offset-blur"></feGaussianBlur>
                <feComposite operator="out" in="SourceGraphic" in2="offset-blur" result="inverse"></feComposite>
                <feFlood flood-color="rgba(0,0,0,.25)" flood-opacity=".35" result="color"></feFlood>
                <feComposite operator="in" in="color" in2="inverse" result="shadow"></feComposite>
                <feComposite operator="over" in="shadow" in2="SourceGraphic"></feComposite>
            </filter>
            <filter id="soft" x="-30%" y="-30%" width="160%" height="160%">
                <feGaussianBlur in="SourceAlpha" stdDeviation="2" result="blur"></feGaussianBlur>
                <feOffset dx="0" dy="2" result="off"></feOffset>
                <feComposite in="off" in2="SourceAlpha" operator="out" result="shadowCut"></feComposite>
                <feColorMatrix in="shadowCut" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .22 0" result="shadow"></feColorMatrix>
                <feMerge>
                    <feMergeNode in="shadow"></feMergeNode>
                    <feMergeNode in="SourceGraphic"></feMergeNode>
                </feMerge>
            </filter>
        </defs>
        <g filter="url(#soft)">
            <path id="blob" fill="url(#mainGradient)" filter="url(#innerShadow)" d="M 101 18 C 126 18, 151 28, 163 50 C 175 72, 177 98, 169 122 C 161 146, 145 169, 121 179 C 97 189, 71 186, 50 173 C 29 160, 18 138, 19 113 C 20 88, 26 61, 44 43 C 62 25, 76 18, 101 18 Z">
                <animate id="toHover" attributeName="d" dur="220ms" fill="freeze" calcMode="spline" keySplines=".2 .9 .2 1" keyTimes="0;1" to="M 101 16 C 129 14, 156 30, 166 53 C 176 76, 175 98, 166 123 C 157 148, 147 170, 121 181 C 95 192, 66 186, 47 172 C 28 158, 17 136, 18 111 C 19 86, 30 63, 46 44 C 62 25, 73 18, 101 16 Z"></animate>
                <animate id="toPressed" attributeName="d" dur="140ms" fill="freeze" calcMode="spline" keySplines=".2 .7 .2 1" keyTimes="0;1" to="M 101 20 C 127 20, 152 32, 162 55 C 172 78, 170 101, 163 125 C 156 149, 144 162, 122 171 C 100 180, 78 181, 63 176 C 50 171, 39 168, 30 156 C 21 144, 18 129, 20 110 C 22 90, 30 67, 47 49 C 64 31, 75 20, 101 20 Z"></animate>
                <animate id="toIdle" attributeName="d" dur="320ms" fill="freeze" calcMode="spline" keySplines=".18 .95 .2 1" keyTimes="0;1" to="M 101 18 C 126 18, 151 28, 163 50 C 175 72, 177 98, 169 122 C 161 146, 145 169, 121 179 C 97 189, 71 186, 50 173 C 29 160, 18 138, 19 113 C 20 88, 26 61, 44 43 C 62 25, 76 18, 101 18 Z"></animate>
                <animate id="afterWobble" attributeName="d" dur="420ms" begin="toIdle.end" fill="freeze" values="M 101 18 C 126 18, 151 28, 163 50 C 175 72, 177 98, 169 122 C 161 146, 145 169, 121 179 C 97 189, 71 186, 50 173 C 29 160, 18 138, 19 113 C 20 88, 26 61, 44 43 C 62 25, 76 18, 101 18 Z;M 101 18 C 128 16, 151 30, 161 52 C 171 74, 175 98, 168 121 C 161 144, 146 170, 121 180 C 96 190, 70 186, 50 172 C 30 158, 19 139, 20 114 C 21 89, 28 63, 46 45 C 64 27, 74 20, 101 18 Z;M 101 18 C 126 18, 151 28, 163 50 C 175 72, 177 98, 169 122 C 161 146, 145 169, 121 179 C 97 189, 71 186, 50 173 C 29 160, 18 138, 19 113 C 20 88, 26 61, 44 43 C 62 25, 76 18, 101 18 Z" keyTimes="0;.55;1" calcMode="spline" keySplines=".2 .9 .2 1; .2 .9 .2 1"></animate>
            </path>
            <text x="100" y="112" text-anchor="middle" class="label">BUTTON</text>
            <path id="highlight" d="M 101 18 C 126 18, 151 28, 163 50 C 175 72, 177 98, 169 122 C 161 146, 145 169, 121 179 C 97 189, 71 186, 50 173 C 29 160, 18 138, 19 113 C 20 88, 26 61, 44 43 C 62 25, 76 18, 101 18 Z" fill="url(#jellyLight)" style="mix-blend-mode: screen; pointer-events:none; transition: fill 280ms cubic-bezier(0.16, 1, 0.3, 1);"></path>
        </g>
    </svg>
</button>
CSS
.jelly-btn {
    appearance: none;
    border: 0;
    background: transparent;
    padding: 0;
    cursor: pointer;
    line-height: 0;
    user-select: none;
    -webkit-tap-highlight-color: transparent;
}

.jelly-btn svg {
    width: 180px;
    height: 180px;
    display: block;
    overflow: visible;
    transform-origin: 50% 50%;
    filter: drop-shadow(0 14px 18px rgba(0, 0, 0, .18));
    transition: filter 220ms cubic-bezier(0.16, 1, 0.3, 1), transform 220ms cubic-bezier(0.16, 1, 0.3, 1);
}

.jelly-btn[data-state="pressed"] svg {
    filter: drop-shadow(0 6px 10px rgba(0, 0, 0, .16));
    transform: translateY(2px) scale(0.98);
}

.jelly-btn:focus-visible {
    outline: 3px solid rgba(50, 222, 132, .45);
    outline-offset: 8px;
    border-radius: 18px;
}

.label {
    font-weight: 700;
    letter-spacing: .04em;
    font-size: 22px;
    fill: #ffffff;
    paint-order: stroke;
    stroke: rgba(0, 0, 0, .08);
    stroke-width: 2px;
}
JavaScript
const btn = document.getElementById('jelly');
const svg = btn.querySelector('svg');
const highlight = btn.querySelector('#highlight');
const toHover = btn.querySelector('#toHover');
const toPressed = btn.querySelector('#toPressed');
const toIdle = btn.querySelector('#toIdle');

let isHover = false;
let isDown = false;
const canSMIL = typeof toHover.beginElement === 'function';
const setState = (state) => btn.setAttribute('data-state', state);

const animateSvg = (keyframes, options) => {
    try {
        const anims = svg.getAnimations();
        anims.forEach(a => {
            a.cancel();
            a.commitStyles && a.commitStyles();
        });
    } catch (_) {}
    if (typeof svg.animate === 'function') {
        return svg.animate(keyframes, options);
    }
};

function goHover(){
    isHover = true;
    if (isDown) return;
    setState('hover');
    if (canSMIL) toHover.beginElement();
    animateSvg(
        [
            { transform: 'scale(1)', offset: 0 },
            { transform: 'scale(1.05)', offset: 0.6 },
            { transform: 'scale(1.02)', offset: 1 }
        ],
        { duration: 450, fill: 'forwards', easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' }
    );
}

function goPressed(){
    isDown = true;
    setState('pressed');
    if (canSMIL) toPressed.beginElement();
    if (highlight) highlight.setAttribute('fill', 'url(#jellyLightPressed)');
    animateSvg(
        [
            { transform: 'scale(1.02)', offset: 0 },
            { transform: 'scale(0.96)', offset: 1 }
        ],
        { duration: 180, fill: 'forwards', easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' }
    );
}

function goIdle(){
    isDown = false;
    setState(isHover ? 'hover' : 'idle');
    if (canSMIL) toIdle.beginElement();
    if (highlight) highlight.setAttribute('fill', 'url(#jellyLight)');
    animateSvg(
        [
            { transform: 'scale(0.96)', offset: 0 },
            { transform: 'scale(1.08)', offset: 0.4 },
            { transform: 'scale(0.98)', offset: 0.7 },
            { transform: 'scale(1.01)', offset: 0.88 },
            { transform: 'scale(1)', offset: 1 }
        ],
        { duration: 550, fill: 'forwards', easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' }
    );
}

btn.addEventListener('pointerenter', () => goHover());

btn.addEventListener('pointerleave', () => {
    isHover = false;
    if (!isDown) {
        setState('idle');
        if (canSMIL) toIdle.beginElement();
        if (highlight) highlight.setAttribute('fill', 'url(#jellyLight)');
        animateSvg(
            [
                { transform: 'scale(1.02)', offset: 0 },
                { transform: 'scale(0.99)', offset: 0.5 },
                { transform: 'scale(1)', offset: 1 }
            ],
            { duration: 400, fill: 'forwards', easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' }
        );
    }
});

btn.addEventListener('pointerdown', (e) => {
    btn.setPointerCapture?.(e.pointerId);
    goPressed();
});

btn.addEventListener('pointerup', () => {
    goIdle();
    if (isHover && canSMIL) setTimeout(() => toHover.beginElement(), 200);
});

btn.addEventListener('pointercancel', () => {
    isDown = false;
    if (canSMIL) toIdle.beginElement();
    setState(isHover ? 'hover' : 'idle');
    if (highlight) highlight.setAttribute('fill', 'url(#jellyLight)');
});

btn.addEventListener('keydown', (e) => {
    if (e.key === ' ' || e.key === 'Enter') {
        if (!isDown) goPressed();
    }
});

btn.addEventListener('keyup', (e) => {
    if (e.key === ' ' || e.key === 'Enter') {
        goIdle();
    }
});

AIへのプロンプト例

以下のように依頼します(形が変形することと、操作ごとの状態をはっきり伝えるのがコツです)。

button要素を使用して、ゼリーのようにぷるぷる弾むボタンを作成してください。

## 表現方法
- SVGを使い、ボタンの形(パス)がなめらかに変形(モーフィング)する

## 初期状態
- ふっくらした有機的な形
- 明るいグラデーション
- 立体感が少しある

## ホバー時
- 形が少しだけ膨らんで、柔らかく反応する

## クリック時
- 押しつぶされるように形が変形する
- 押したあとに少し揺れて落ち着く

このボタンの特徴

  • 形そのものが変形 d(パス)をアニメーションして「柔らかい質感」を直接表現します。
  • 状態が分かりやすい ホバーと押下で形が変わるため、操作感が伝わります。
  • 装飾もSVGで完結 グラデーションやハイライトで立体感を作れます。

コードのポイント

1. ボタンの「形」はどこで決まっているの?
SVG
<path id="blob" d="M 101 18 C 126 18, 151 28...">
    <animate id="toHover" attributeName="d" ...></animate>
    <animate id="toPressed" attributeName="d" ...></animate>
    <animate id="toIdle" attributeName="d" ...></animate>
</path>
  • d属性が形の設計図
    • d="M 101 18 C 126 18..." という部分に、ボタンの輪郭が数字で書かれています。この数字が変わると、ボタンの形が変わります
  • <animate>でその設計図を書き換える
    • ホバー時・押下時・戻る時で、それぞれ違う形の設計図(dの値)を指定しているため、形がなめらかに変形します
  • 結果:押すと本当に形が変わる
    • 画像を差し替えるのではなく、輪郭そのものが動くので、ゼリーのような有機的な動きになります
2. 色と影はどうやって作っているの?
SVG
<defs>
    <linearGradient id="mainGradient" ...>
        <stop offset="0%" stop-color="#32de84"></stop>
        <stop offset="100%" stop-color="#f6f930"></stop>
    </linearGradient>

    <filter id="soft" ...>
        <feGaussianBlur ...></feGaussianBlur>
        ...
    </filter>
</defs>
  • <defs>は「見た目の部品置き場」
    • ここで色や影の設定を作っておき、あとで使い回します
  • linearGradientでグラデーション
    • 緑(#32de84)から黄色(#f6f930)へなめらかに色が変わるグラデーションを定義しています。これがゼリーっぽい透明感を演出します
  • filterでふわっとした影
    • feGaussianBlurなどを使って、ボタンの下に柔らかい影を作ります。カチッとした影ではなく、ぼかしがかかった優しい影になります
  • 結果:立体感が生まれる
    • グラデーションと影があることで、平面ではなく「ぷくっと膨らんだゼリー」に見えます
3. マウス操作でアニメーションを切り替える仕組み
JavaScript
btn.addEventListener('pointerenter', () => goHover());
btn.addEventListener('pointerdown', (e) => {
    btn.setPointerCapture?.(e.pointerId);
    goPressed();
});
btn.addEventListener('pointerup', () => goIdle());
  • pointerenterはマウスが乗った瞬間
    • ボタンの上にマウスが来たらgoHover()を実行して、「ホバー用の形」に変形させます
  • pointerdownは押した瞬間
    • マウスボタンを押したらgoPressed()を実行して、「押された形」に変形させます
  • pointerupは離した瞬間
    • マウスボタンを離したらgoIdle()を実行して、元の形に戻します
  • setPointerCaptureで追跡を続ける
    • これを使うと、押したままドラッグしてボタンから外れても、「このボタンを押している最中」という状態が保たれます。途中で動きが止まらず、スムーズに操作できます
4. キーボードでも押せるようにする(アクセシビリティ対応)
JavaScript
btn.addEventListener('keydown', (e) => {
    if (e.key === ' ' || e.key === 'Enter') {
        if (!isDown) goPressed();
    }
});

btn.addEventListener('keyup', (e) => {
    if (e.key === ' ' || e.key === 'Enter') {
        goIdle();
    }
});
  • スペースキーかEnterキーで押せる
    • e.keyでどのキーが押されたか判定して、スペースまたはEnterの場合だけgoPressed()を実行します
  • なぜこれが必要?
    • マウスが使えない人(視覚障害のある人など)は、キーボードだけでWebサイトを操作します。この対応をしておくと、誰でも使えるボタンになります
  • 結果:どんな人でも使える
    • マウスでもキーボードでも同じように動くので、ユーザー全員に優しいボタンになります。これを「アクセシビリティ対応」と呼びます
SMILは何をしているの?(初心者向け)

SMILは、SVGの中に書けるアニメーションの仕組みです。<animate>タグで「どの属性を」「どれくらいの時間で」「どんな値に変えるか」を指定します。

このボタンでは、attributeName="d"でパスの形(輪郭)を変形させています。

なお、SMILは環境によって対応差があります。そのため、この例ではSMILが使えない場合でも、スケール(拡大縮小)のアニメーションはJavaScript側(svg.animate(...))で動くようにしています。

2. 流体シェイプボタン(CSS + Web Animations API版)

CSSのborder-radiusとWeb Animations APIで、少ないコード量で柔らかな動きを作ります。

HTML
<button class="jelly-btn" type="button" aria-label="ゼリーボタン" id="cssBtn">
    <span class="label-css">BUTTON</span>
</button>
CSS
.jelly-btn {
    appearance: none;
    border: 0;
    padding: 0;
    cursor: pointer;
    user-select: none;
    -webkit-tap-highlight-color: transparent;
    position: relative;
    width: 150px;
    height: 150px;
    background: linear-gradient(135deg, #00d2ff, #7b2cbf);
    border-radius: 42% 58% 70% 30% / 45% 55% 45% 55%;
    filter: drop-shadow(0 14px 18px rgba(0, 0, 0, .18));
    transition: all 220ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
    overflow: visible;
}

.jelly-btn::before {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: inherit;
    background: radial-gradient(circle at 35% 30%,
            rgba(255, 255, 255, .65) 0%,
            rgba(255, 255, 255, .25) 35%,
            rgba(255, 255, 255, .05) 70%,
            transparent 100%);
    mix-blend-mode: screen;
    pointer-events: none;
    transition: opacity 280ms cubic-bezier(0.16, 1, 0.3, 1);
}

.label-css {
    position: relative;
    z-index: 1;
    font-weight: 700;
    letter-spacing: .04em;
    font-size: 22px;
    color: #ffffff;
    text-shadow: 0 0 2px rgba(0, 0, 0, .08);
}

.jelly-btn:hover {
    border-radius: 45% 55% 60% 40% / 50% 48% 52% 50%;
    transform: scale(1.02);
}

.jelly-btn:active {
    filter: drop-shadow(0 6px 10px rgba(0, 0, 0, .16));
    transform: translateY(2px) scale(0.98);
    border-radius: 48% 52% 65% 35% / 52% 50% 50% 48%;
}

.jelly-btn:active::before {
    opacity: 0.5;
    background: radial-gradient(circle at 65% 70%,
            rgba(255, 255, 255, .25) 0%,
            rgba(255, 255, 255, .08) 45%,
            transparent 100%);
}

.jelly-btn:focus-visible {
    outline: 3px solid rgba(50, 222, 132, .45);
    outline-offset: 8px;
}
JavaScript
const cssBtn = document.getElementById('cssBtn');
let isHovering = false;
let activeAnim = null;

cssBtn.addEventListener('pointerenter', () => {
    isHovering = true;
    if(activeAnim) activeAnim.cancel();

    activeAnim = cssBtn.animate([
        {
            borderRadius: '42% 58% 70% 30% / 45% 55% 45% 55%',
            transform: 'scale(1)',
            offset: 0
        },
        {
            borderRadius: '44% 56% 66% 34% / 47% 53% 47% 53%',
            transform: 'scale(1.02)',
            offset: 0.2
        },
        {
            borderRadius: '47% 53% 61% 39% / 50% 50% 50% 50%',
            transform: 'scale(1.05)',
            offset: 0.4
        },
        {
            borderRadius: '48% 52% 58% 42% / 51% 49% 51% 49%',
            transform: 'scale(1.04)',
            offset: 0.6
        },
        {
            borderRadius: '46% 54% 60% 40% / 50% 50% 50% 50%',
            transform: 'scale(1.02)',
            offset: 0.8
        },
        {
            borderRadius: '45% 55% 60% 40% / 50% 48% 52% 50%',
            transform: 'scale(1.02)',
            offset: 1
        }
    ], {
        duration: 520,
        fill: 'forwards',
        easing: 'ease-out'
    });
});

cssBtn.addEventListener('pointerleave', () => {
    isHovering = false;
    if(activeAnim) activeAnim.cancel();

    activeAnim = cssBtn.animate([
        {
            borderRadius: '45% 55% 60% 40% / 50% 48% 52% 50%',
            transform: 'scale(1.02)',
            offset: 0
        },
        {
            borderRadius: '41% 59% 72% 28% / 44% 56% 44% 56%',
            transform: 'scale(0.97)',
            offset: 0.25
        },
        {
            borderRadius: '44% 56% 66% 34% / 48% 52% 48% 52%',
            transform: 'scale(1.03)',
            offset: 0.48
        },
        {
            borderRadius: '42% 58% 69% 31% / 45.5% 54.5% 45.5% 54.5%',
            transform: 'scale(0.99)',
            offset: 0.68
        },
        {
            borderRadius: '43% 57% 68% 32% / 46.5% 53.5% 46.5% 53.5%',
            transform: 'scale(1.01)',
            offset: 0.85
        },
        {
            borderRadius: '42% 58% 70% 30% / 45% 55% 45% 55%',
            transform: 'scale(1)',
            offset: 1
        }
    ], {
        duration: 850,
        fill: 'forwards',
        easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)'
    });
});

cssBtn.addEventListener('pointerdown', () => {
    if(activeAnim) activeAnim.cancel();

    activeAnim = cssBtn.animate([
        {
            borderRadius: '45% 55% 60% 40% / 50% 48% 52% 50%',
            transform: 'scale(1.02)',
            offset: 0
        },
        {
            borderRadius: '47% 53% 62% 38% / 52% 48% 52% 48%',
            transform: 'scale(0.99)',
            offset: 0.3
        },
        {
            borderRadius: '49% 51% 64% 36% / 53% 47% 53% 47%',
            transform: 'scale(0.97)',
            offset: 0.6
        },
        {
            borderRadius: '50% 50% 65% 35% / 54% 46% 54% 46%',
            transform: 'scale(0.96)',
            offset: 1
        }
    ], {
        duration: 160,
        fill: 'forwards',
        easing: 'ease-out'
    });
});

cssBtn.addEventListener('pointerup', () => {
    if(activeAnim) activeAnim.cancel();

    const target = isHovering ?
        { radius: '45% 55% 60% 40% / 50% 48% 52% 50%', scale: 1.02 } :
        { radius: '42% 58% 70% 30% / 45% 55% 45% 55%', scale: 1 };

    activeAnim = cssBtn.animate([
        {
            borderRadius: '50% 50% 65% 35% / 54% 46% 54% 46%',
            transform: 'scale(0.96)',
            offset: 0
        },
        {
            borderRadius: '40% 60% 56% 44% / 43% 57% 43% 57%',
            transform: 'scale(1.1)',
            offset: 0.28
        },
        {
            borderRadius: '47% 53% 68% 32% / 50% 50% 50% 50%',
            transform: 'scale(0.96)',
            offset: 0.5
        },
        {
            borderRadius: '43% 57% 63% 37% / 47% 53% 47% 53%',
            transform: 'scale(1.05)',
            offset: 0.7
        },
        {
            borderRadius: '42.5% 57.5% 68.5% 31.5% / 46% 54% 46% 54%',
            transform: 'scale(' + (target.scale - 0.01) + ')',
            offset: 0.87
        },
        {
            borderRadius: target.radius,
            transform: 'scale(' + target.scale + ')',
            offset: 1
        }
    ], {
        duration: 900,
        fill: 'forwards',
        easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)'
    });
});

AIへのプロンプト例

CSSのborder-radiusを「形」として使うときは、ホバー・押下の“変形のイメージ”を言葉で伝えると狙いに近づきます。

button要素を使用して、ゼリーのように柔らかく弾むボタンを作成してください。

## 初期状態
- 150px前後の正方形に近いボタン
- グラデーション背景
- 角はカチッとした丸ではなく、有機的な形(border-radiusが不規則)

## ホバー時
- 少し膨らむ
- 形がゆらっと変形する

## クリック時
- 押し込まれて少し縮む
- 離したあとに、オーバーシュートしながら元に戻る

## 実装ヒント
- hover/activeだけでなく、JavaScriptでアニメーション制御してもよい

このボタンの特徴

  • 実装が軽め SVGのモーフィングより、導入コストが低いです。
  • 動きの演出が得意 Web Animations APIで「戻る時の跳ね返り」まで作り込めます。
  • 見た目の変形 実体の形状というより、border-radiusの比率を変えて“それっぽい”形を作ります。

コードのポイント

1. JavaScriptで「形」と「大きさ」を同時に変える
JavaScript
activeAnim = cssBtn.animate([
    { borderRadius: '42% 58% 70% 30% / 45% 55% 45% 55%', transform: 'scale(1)' },
    { borderRadius: '44% 56% 66% 34% / 47% 53% 47% 53%', transform: 'scale(1.05)' },
    { borderRadius: '45% 55% 60% 40% / 50% 48% 52% 50%', transform: 'scale(1.02)' }
], {
    duration: 520,
    fill: 'forwards'
});
  • borderRadiusで角の丸みを変える
    • 42% 58%...という数字が変わることで、ボタンの輪郭が「ぷくっと膨らんだり」「少し細くなったり」します
  • transform: scale(...)で大きさを変える
    • scale(1)は通常サイズ、scale(1.05)は5%拡大です。大きさも同時に変えることで、「膨らむ→少し戻る」という揺れる動きができます
  • animate()で細かい動きを作る
    • CSSの:hoverだと「A→B」の単純な変化しかできませんが、animate()を使うと「A→B→C」のように途中に状態を挟めます。これで「揺れる」動きが作れます
  • duration: 520で動きの速さを調整
    • 520ミリ秒(約0.5秒)かけてアニメーションします。これより短いと急すぎ、長いともたつきます
CSS
border-radius: 42% 58% 70% 30% / 45% 55% 45% 55%;

スラッシュの左が横方向、右が縦方向の丸みです。これを変えると、有機的な輪郭が作れます。

3. 光っているように見せる工夫(疑似要素)
CSS
.jelly-btn::before {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: inherit;
    background: radial-gradient(circle at 35% 30%,
        rgba(255, 255, 255, .65) 0%,
        transparent 100%);
    mix-blend-mode: screen;
    pointer-events: none;
}
  • ::beforeで透明な層を重ねる
    • 疑似要素を使うと、HTMLを増やさずにCSSだけで「重ねる層」を作れます。ここでは白いグラデーションの層を重ねています
  • radial-gradientで中心が明るい光
    • 中心(35%, 30%の位置)が白く、外側に向かって透明になるグラデーションです。これがゼリーの「光沢」に見えます
  • mix-blend-mode: screenで明るく合成
    • screenは「加算合成」で、下の色を明るくします。普通に重ねるより、光っているように見えます
  • pointer-events: noneでクリックを通す
    • これがないと、重ねた透明な層がクリックを奪ってしまいます。noneにすることで、クリックが下のボタンに届きます
  • 結果:立体的な光沢が生まれる
    • 上から光が当たっているような質感になり、ゼリーのぷるぷる感が増します
4. 素早く操作しても壊れない工夫
JavaScript
if (activeAnim) activeAnim.cancel();
activeAnim = cssBtn.animate(...);
  • アニメーションの「上書き」をする
    • 新しいアニメーションを始める前に、前のアニメーションを止めます(cancel())。これをしないと、複数のアニメーションが同時に動いて見た目が崩れます
  • どんな時に必要?
    • ユーザーがボタンの上で素早くマウスを動かしたり、連打したりすると、アニメーションが終わる前に次のアニメーションが始まります
  • 例:ホバー→すぐ離れる→またホバー
    • ホバーアニメーション開始 → 途中でマウスが離れる → 戻るアニメーション開始 → すぐまたホバー → 新しいホバーアニメーション開始
    • この時、前のアニメをキャンセルしないと、動きがガタガタになります
  • 結果:どんな操作でもスムーズ
    • 連打しても、素早くホバーを繰り返しても、常に最新の状態に合ったアニメーションが動きます

まとめ

男子生徒のアイコン

SVGの方は形そのものが変わって、CSSの方は形っぽく見せる感じなんですね。

AI先生のアイコン

そういうことです。どちらも「状態(ホバー・押下・解除)を分けて設計する」と、触って気持ちいい動きに近づきます。

女子生徒のアイコン

まずはCSS版で作って、もっとこだわりたくなったらSVG版に挑戦するのが良さそうです。

AI先生のアイコン

その順番はおすすめです。まず動きの設計に慣れて、次に表現の幅を広げると進めやすいです。

流体シェイプボタンのポイント
  • 状態を設計する 初期・ホバー・押下・解除を分けると動きが破綻しにくいです。
  • SVG版の強み 形そのもの(パス)を変形でき、質感表現の幅が広いです。
  • CSS版の強み 実装が軽く、Web Animations APIで跳ね返りなどを作りやすいです。
  • 連続操作の対策 アニメをキャンセルしてから再実行すると操作が安定します。

学習チェック

このレッスンを理解できたら「完了」をクリックしてください。
後で見直したい場合は「未完了に戻す」で進捗をリセットできます。

レッスン完了!🎉

お疲れさまでした!