JavaScriptとCSSアニメーションを組み合わせると、短いコードでも「動くミニデモ」を作れます。今回は、バスケットボールが床に当たってバウンドする表現を作ります。
アニメーションの本体はCSSの@keyframesで作り、JavaScriptではボタンを押した時にバウンド速度を変えるだけにします。CodePenへ貼って動かせるコードも用意しました。
作るもの
- バスケットボールが上下にバウンドするデモ
- 床に近い時だけボールが少し潰れる表現
- 影の大きさと透明度がボールの高さに合わせて変わる表現
- Slow / Normal / Fastボタンで速度を切り替える操作
- prefers-reduced-motionで動きを減らす設定にも対応
実装の考え方
ボールの上下移動はtransform: translateY()で表現します。床に当たる瞬間だけscale(1.08, .92)のように横へ少し広げ、縦へ少し潰すと、軽い弾性が出ます。
@keyframes bounce {
0%, 100% {
transform: translate(-50%, 0) scale(1.08, .92);
}
55% {
transform: translate(-50%, -250px) scale(1);
}
}
影はボールとは逆に、高く跳ねている時ほど小さく薄くします。これだけで、床との距離がかなり分かりやすくなります。
CodePen埋め込み用エリア
CodePenでPenを作ったら、上の枠をCodePenの埋め込みタグに差し替えてください。下のコードはHTML、CSS、JSの各パネルへそのまま貼れます。
HTML
<main class="bounce-demo">
<section class="stage" aria-label="バウンドするバスケットボールのデモ">
<div class="ball" aria-hidden="true"></div>
<div class="shadow" aria-hidden="true"></div>
</section>
<div class="controls">
<button type="button" data-speed="slow">Slow</button>
<button type="button" data-speed="normal" class="is-active">Normal</button>
<button type="button" data-speed="fast">Fast</button>
</div>
</main>
CSS
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
display: grid;
place-items: center;
padding: 24px;
background: #111;
color: #fff;
font-family: system-ui, sans-serif;
}
.bounce-demo {
width: min(100%, 760px);
}
.stage {
position: relative;
height: min(62vh, 460px);
overflow: hidden;
border: 2px solid rgb(255 255 255 / .18);
border-radius: 28px;
background:
radial-gradient(circle at 50% 20%, rgb(255 211 41 / .16), transparent 28%),
linear-gradient(#202020, #111);
}
.stage::after {
content: "";
position: absolute;
right: 0;
bottom: 72px;
left: 0;
height: 3px;
background: #ff2f63;
box-shadow: 0 0 24px rgb(255 47 99 / .5);
}
.ball {
--duration: .88s;
position: absolute;
left: 50%;
bottom: 76px;
width: clamp(82px, 16vw, 132px);
aspect-ratio: 1;
transform: translateX(-50%);
border-radius: 50%;
background:
radial-gradient(circle at 32% 24%, #ffbd62 0 9%, transparent 10%),
radial-gradient(circle at 35% 28%, #f28b2d, #c8601b 68%, #7a330c 100%);
box-shadow: inset -18px -22px 28px rgb(0 0 0 / .25);
animation: bounce var(--duration) cubic-bezier(.34, .02, .62, 1) infinite;
}
.ball::before,
.ball::after {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
}
.ball::before {
border: 4px solid #2a1307;
clip-path: polygon(0 44%, 100% 44%, 100% 58%, 0 58%);
}
.ball::after {
background:
linear-gradient(90deg, transparent 47%, #2a1307 48% 52%, transparent 53%),
radial-gradient(circle at 50% 50%, transparent 0 42%, #2a1307 43% 46%, transparent 47%);
opacity: .95;
}
.shadow {
--duration: .88s;
position: absolute;
left: 50%;
bottom: 52px;
width: clamp(88px, 17vw, 142px);
height: 18px;
transform: translateX(-50%);
border-radius: 50%;
background: rgb(0 0 0 / .42);
filter: blur(4px);
animation: shadow var(--duration) ease-in-out infinite;
}
.controls {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 20px;
}
.controls button {
min-width: 88px;
min-height: 44px;
border: 2px solid #fff;
border-radius: 999px;
background: transparent;
color: #fff;
font-weight: 800;
cursor: pointer;
}
.controls button:hover,
.controls button:focus-visible,
.controls button.is-active {
border-color: #ffd329;
background: #ffd329;
color: #111;
}
@keyframes bounce {
0%, 100% {
transform: translate(-50%, 0) scale(1.08, .92);
}
12% {
transform: translate(-50%, -8px) scale(.96, 1.04);
}
55% {
transform: translate(-50%, -250px) scale(1);
}
}
@keyframes shadow {
0%, 100% {
opacity: .55;
transform: translateX(-50%) scaleX(1);
}
55% {
opacity: .16;
transform: translateX(-50%) scaleX(.48);
}
}
@media (prefers-reduced-motion: reduce) {
.ball,
.shadow {
animation: none;
}
}
JS
const ball = document.querySelector(".ball");
const shadow = document.querySelector(".shadow");
const buttons = document.querySelectorAll("[data-speed]");
const speeds = {
slow: "1.2s",
normal: ".88s",
fast: ".58s",
};
buttons.forEach((button) => {
button.addEventListener("click", () => {
const duration = speeds[button.dataset.speed] || speeds.normal;
ball.style.setProperty("--duration", duration);
shadow.style.setProperty("--duration", duration);
buttons.forEach((item) => {
item.classList.toggle("is-active", item === button);
});
});
});
JavaScriptの役割
JavaScriptでは、ボタンのdata-speedを読み取り、CSSカスタムプロパティ--durationを書き換えています。動きそのものをJavaScriptで毎フレーム計算するより、CSSアニメーションへ任せる方がシンプルです。
const speeds = {
slow: '1.2s',
normal: '.88s',
fast: '.58s',
};
ball.style.setProperty('--duration', speeds.fast);
もっと自然に見せる調整
- 最高到達点:
translateY(-250px)の値を大きくすると高く跳ねます。 - 速度:
--durationを短くするとテンポが速くなります。 - 重さ:床に当たる時間を短くし、上昇・落下を速くすると重く見えます。
- 影:高い時に小さく薄く、低い時に大きく濃くすると距離感が出ます。
- 床ライン:ボールの接地点にラインや影を置くと、着地位置が分かりやすくなります。
よくある失敗
- topやbottomをアニメーションする:レイアウト計算が発生しやすいので、基本は
transformを使います。 - 影を固定したままにする:ボールだけ動いて見え、浮遊感が弱くなります。
- ボールの模様を複雑にしすぎる:小さい表示では潰れるので、線は少なめで十分です。
- 速度切り替えをJSだけで作る:CSS変数へ渡すと、CSS側のアニメーション設計を保てます。
- 動きを止める配慮がない:
prefers-reduced-motionでアニメーションを停止できるようにします。
まとめ
バウンド表現は、上下移動、着地時の潰れ、影の変化を合わせると一気にそれっぽくなります。JavaScriptは速度変更などの状態管理に絞り、実際の動きはCSSアニメーションへ任せると、コードも読みやすくなります。
CodePenで試す時は、まず高さと速度を触ってみてください。少し値を変えるだけでも、軽いボール、重いボール、コミカルな動きなど、かなり印象が変わります。