アニメーション
CSSとJSで作る、視線がマウスを追いかけてクリックで瞬きする猫
2026/02/16
2026/2/9
画像の上に「生きているパーツ」を載せるだけで、コンテンツの印象は一気に変わります。
このサンプルは、目玉の瞳がカーソルを追いかけ、クリックで瞬きをする演出を、CSSとJS(React)で組み立てたものです。PC/スマホで背景を差し替えつつ、左右の目の位置を個別に調整できるようにしてあります。
Preview プレビュー
Code コード
<div id="kumonosu-root"></div>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--kumonosu-eye-white: hsl(0 0% 100% / 0.9);
--kumonosu-pupil-color: hsl(0 0% 0%);
}
body {
/* background-size: auto により画像を拡大縮小させず、元サイズで表示します */
background-image: url('https://kumonosu.net/wp-content/uploads/2026/02/neko.jpg');
background-repeat: no-repeat;
background-position: center;
background-size: auto;
display: grid;
place-items: center;
height: 100vh;
width: 100vw;
overflow: hidden;
user-select: none;
background-color: #000;
}
.kumonosu-eyes-container {
display: flex;
gap: 6px;
pointer-events: none;
margin-top: -77px;
}
.kumonosu-eye {
position: relative;
width: 28px;
height: 28px;
background-color: hsl(0deg 0% 81.14%);
border-radius: 50%;
border: 3px solid hsl(171.43deg 2.28% 25.2%);
overflow: hidden;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
}
.kumonosu-left-eye {
transform: translate(0px, 0px);
}
.kumonosu-right-eye {
transform: translate(0px, -5px);
}
.kumonosu-eye.kumonosu-is-blinking {
animation: kumonosu-eye-blink-once 0.15s ease-in-out;
}
.kumonosu-pupil {
position: absolute;
top: 50%;
left: 50%;
width: 12px;
height: 12px;
background-color: var(--kumonosu-pupil-color);
border-radius: 50%;
transform: translate(-50%, -50%);
transition: transform 0.1s ease-out;
}
@keyframes kumonosu-eye-blink-once {
0%, 100% {
transform: scaleY(1);
}
50% {
transform: scaleY(0.01);
}
}
import React, { useEffect, useState, useRef } from 'https://esm.sh/react@18.2.0';
import ReactDOM from 'https://esm.sh/react-dom@18.2.0/client';
import htm from 'https://esm.sh/htm';
const html = htm.bind(React.createElement);
function CreepyEyes() {
const [eyeCoords, setEyeCoords] = useState({ x: 0, y: 0 });
const [isBlinking, setIsBlinking] = useState(false);
const containerRef = useRef(null);
useEffect(() => {
const handleMove = (e) => {
if (!containerRef.current) return;
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
const rect = containerRef.current.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const dx = clientX - centerX;
const dy = clientY - centerY;
const angle = Math.atan2(dy, dx);
const maxDist = rect.height * 0.3;
const distance = Math.min(Math.hypot(dx, dy) / 10, maxDist);
const x = Math.cos(angle) * distance;
const y = Math.sin(angle) * distance;
setEyeCoords({ x, y });
};
const handleGlobalClick = () => {
setIsBlinking(true);
};
window.addEventListener('mousemove', handleMove);
window.addEventListener('touchstart', handleGlobalClick);
window.addEventListener('touchmove', handleMove, { passive: false });
window.addEventListener('click', handleGlobalClick);
return () => {
window.removeEventListener('mousemove', handleMove);
window.removeEventListener('touchstart', handleGlobalClick);
window.removeEventListener('touchmove', handleMove);
window.removeEventListener('click', handleGlobalClick);
};
}, []);
const handleAnimationEnd = () => {
setIsBlinking(false);
};
const pupilStyle = {
transform: `translate(calc(-50% + ${eyeCoords.x}px), calc(-50% + ${eyeCoords.y}px))`
};
return html`
<div className="kumonosu-eyes-container" ref=${containerRef}>
<div
className=${`kumonosu-eye kumonosu-left-eye ${isBlinking ? 'kumonosu-is-blinking' : ''}`}
onAnimationEnd=${handleAnimationEnd}
>
<div className="kumonosu-pupil" style=${pupilStyle}></div>
</div>
<div
className=${`kumonosu-eye kumonosu-right-eye ${isBlinking ? 'kumonosu-is-blinking' : ''}`}
>
<div className="kumonosu-pupil" style=${pupilStyle}></div>
</div>
</div>
`;
}
const root = ReactDOM.createRoot(document.getElementById("kumonosu-root"));
root.render(html`<${CreepyEyes} />`);
Explanation 詳しい説明
仕様
目は2つの円(白目)と、その中にある円(瞳)で作っています。
瞳はtransform: translate()で位置を動かし、常に目の中心から一定距離以内に収まるように制限しています。
動かし方は「カーソル位置と目の中心との差分」から角度を求め、cos/sinでx/y成分に変換する方式です。距離は上限を設けているため、カーソルがどれだけ離れても瞳が飛び出しません。
瞬きはCSSアニメーションで、クリック(タップ)をトリガーにis-blinkingクラスを付け、scaleYで縦方向に潰して一瞬で閉じる表現にしています。アニメーション終了後は状態を戻して連続クリックにも対応します。
- 瞳の追従:目の中心→カーソルへの角度を計算して移動
- 移動量の制限:最大距離を決めて目からはみ出さないようにする
- 瞬き:
scaleYの短いアニメーションをクリックで発火 - PC/スマホ:背景画像と目サイズ・配置をメディアクエリで切り替え
カスタム
背景画像の差し替えと、目の位置合わせが主な調整ポイントです。目は画像の上に“置く”発想なので、写真が変われば位置も変わります。
PCとスマホで別画像を使っているため、それぞれで位置を微調整できるようになっています。
- 目の全体位置:
.eyes-containerのmargin-top - 左右の微調整:
.left-eye/.right-eyeのtransform: translate(x, y) - 目のサイズ:
.eyeのwidth/height、瞳は.pupil - 追従の強さ:
maxDist(PC/スマホ別)やdistance計算の係数(/15)
瞬きの“速さ”や“重さ”は、@keyframesの時間(0.15s)とイージングで印象が変わります。もっと自然にするなら、閉じる→少し止まる→開くの3段階にしてもOKです。
注意点
イベントはmousemoveとtouchmoveで常に座標計算が走るため、端末によっては負荷が増えることがあります。
必要ならrequestAnimationFrameでまとめて更新する、または一定間隔で間引くと安定します。
また、瞬きは「左目のアニメ終了だけ」で状態を戻しています。左右でアニメがズレる/将来別々に動かす場合は、両目の終了を待つか、タイマーで戻すなど管理方法を揃えると安全です。
mousemove/touchmoveは高頻度(重い場合は更新を間引く)- 瞬き状態解除が片目基準(左右別制御にするなら見直し)
- 画像差し替え時は、PC/スマホ両方で位置合わせが必要
イラストの提供
イラストの提供はkasumi nakatakeさんです。
とても可愛い猫をありがとうございます。