作成した流体シェイプボタン
どちらも共通して、初期状態 → ホバー → 押下 → 離すという「状態」を分けて考えると、動きが整理しやすくなります。
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」のように途中に状態を挟めます。これで「揺れる」動きが作れます
- CSSの
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版の強み 実装が軽く、Web Animations APIで跳ね返りなどを作りやすいです。
- 連続操作の対策 アニメをキャンセルしてから再実行すると操作が安定します。