CSSとJSで作る、視線がマウスを追いかけてクリックで瞬きする猫

アニメーション

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-containermargin-top
  • 左右の微調整:.left-eye / .right-eyetransform: translate(x, y)
  • 目のサイズ:.eyewidth/height、瞳は .pupil
  • 追従の強さ:maxDist(PC/スマホ別)や distance 計算の係数(/15

瞬きの“速さ”や“重さ”は、@keyframesの時間(0.15s)とイージングで印象が変わります。もっと自然にするなら、閉じる→少し止まる→開くの3段階にしてもOKです。

注意点

イベントはmousemovetouchmoveで常に座標計算が走るため、端末によっては負荷が増えることがあります。

必要ならrequestAnimationFrameでまとめて更新する、または一定間隔で間引くと安定します。

また、瞬きは「左目のアニメ終了だけ」で状態を戻しています。左右でアニメがズレる/将来別々に動かす場合は、両目の終了を待つか、タイマーで戻すなど管理方法を揃えると安全です。

  • mousemove/touchmoveは高頻度(重い場合は更新を間引く)
  • 瞬き状態解除が片目基準(左右別制御にするなら見直し)
  • 画像差し替え時は、PC/スマホ両方で位置合わせが必要

イラストの提供

イラストの提供はkasumi nakatakeさんです。

とても可愛い猫をありがとうございます。