GODRICH

「カーソル エフェクト css」9選 — 一行でコピペOK

カーソル エフェクト cssは、既定の矢印カーソルを消し、代わりの要素をtransformで動かして見せる、CSSと数行のJavaScriptで作る演出です。この記事では9種類を選び、cursor: noneとpointermove、transformだけで実装しました。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

並び順は効果の凝った具合ではなく、pointermoveで取得した座標をどこまで使い回すかという段階です。まず矢印そのものを別の形に差し替える基本形から始め(01)、同じ座標にわずかな遅延を足してリングを追従させ(02)、その遅延した座標を要素自身の移動やテキストの表示へ応用し(03・04)、円ひとつの見た目をmix-blend-modeや複数の残像へ広げ(05・06)、:has()によるホバー判定を重ね(07)、最後に土台となるcursor: noneの設定(08)とタッチ端末での安全な代替(09)という前提部分を置きました。

01カスタムカーソル画像

実際のサイトではcursor: url("cursor.svg") 12 12, auto;のように矢印そのものを画像やSVGへ差し替えますが、ヘッドレス環境ではOSのカーソル画像が映らないため、このデモでは同じ雫形をDOM要素として再現し、pointermoveで取得した座標をtransformに渡して動かしています。ブランドロゴのランディングページや、ゲームのようなクリック体験を作りたいサイトに向いています。

cursor: url()hotspot 좌표fallback 커서
.stage { position: relative; overflow: hidden; cursor: none; }

.cursor {
  position: absolute;
  top: 0;
  left: 0;
  width: 26px;
  height: 26px;
  border-radius: 50% 50% 50% 4px;
  background: $color;
  box-shadow: $shadow-press;
  transform: translate(var(--x, 60px), var(--y, 40px)) rotate(-45deg);
  pointer-events: none;
}

02リングが追従

pointermoveで得たx・y座標を点とリングの両方へ同じtransform: translateとして渡していますが、リングだけにtransition: transform 180msを掛けているため、点より一瞬遅れてついてくるように見えます。ポートフォリオのヒーローエリアや、クリックできる範囲をさりげなく示したいリンク周りに向いています。

pointermovetransform: translate지연 추적
.dot,
.ring {
  position: absolute;
  top: 0;
  left: 0;
  border-radius: 50%;
  pointer-events: none;
}

.dot {
  width: 8px;
  height: 8px;
  background: $color;
}

.ring {
  width: 34px;
  height: 34px;
  margin: -13px 0 0 -13px;
  border: 2px solid $color;
  transition: transform 180ms $easing;
}

03磁石のように引き寄せるボタン

ボタン中心と実際のポインター座標の差分dxdyから距離distを求め、90px以内なら距離に反比例するpullの値でボタン自身をtranslateさせています。CTAボタンや、ヘッダーで目立たせたいリンクに向いています。

pointermove거리 벡터transform: translate
stage.addEventListener('pointermove', function (e) {
  var r = stage.getBoundingClientRect();
  var x = e.clientX - r.left, y = e.clientY - r.top;
  var br = btn.getBoundingClientRect();
  var cx = br.left - r.left + br.width / 2;
  var cy = br.top - r.top + br.height / 2;
  var dx = x - cx, dy = y - cy;
  var dist = Math.hypot(dx, dy);
  var pull = dist < 90 ? (90 - dist) / 90 : 0;
  btn.style.transform =
    'translate(' + (dx * pull * .35) + 'px,' + (dy * pull * .35) + 'px)';
});

04ホバーでテキスト表示

pointermoveのイベント先をカードが含んでいるかをcard.contains(e.target)で判定し、含んでいれば吹き出しのopacityを1にして「見る」という短い案内を表示します。位置は同じ座標をそのままtransform: translateへ渡しているだけなので、文言だけ変えれば「ドラッグ」のような別の指示にも流用できます。

pointermoveopacity 전환라벨 텍스트
.label {
  position: absolute;
  top: 0;
  left: 0;
  padding: clamp(5px, 1.6vw, 7px) clamp(10px, 3vw, 14px);
  border-radius: $r-pill;
  background: $color;
  color: #fff;
  opacity: 0;
  transition: opacity 140ms $easing;
  pointer-events: none;
  transform: translate(-50%, -140%);
}

05mix-blend-modeカーソル

直径70pxの円にmix-blend-mode: differenceを指定すると、円の下にある背景色との差分が計算され、暗い面では白く・明るい面では暗く反転して見えます。ダークとライトのセクションが混在するワンページサイトで、背景を問わずカーソルを常に視認させたいときに使えます。

mix-blend-modedifferencez-index
.cursor {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 2;
  width: 70px;
  height: 70px;
  margin: -35px 0 0 -35px;
  border-radius: 50%;
  background: $color;
  mix-blend-mode: difference;
  pointer-events: none;
}

06残像トレイル

@forで6つのspanそれぞれにanimation-delayをマイナスの値で少しずつずらして与え、opacityも番号が進むごとに薄くしているので、同じ経路を追いながら透明度が違う点が尾のように連なって見えます。実際の操作では先頭の点だけがpointermoveの座標に直接反応し、残りの点はCSSのアニメーションだけで遅れてついてきます。クリエイティブなポートフォリオや、インタラクティブなアート系ランディングに向いています。

requestAnimationFrameopacity 트랜지션잔상 점
.trail span {
  position: absolute;
  top: 0;
  left: 0;
  width: 8px;
  height: 8px;
  margin: -4px 0 0 -4px;
  border-radius: 50%;
  background: $color;
  pointer-events: none;
}

@for $i from 1 through 6 {
  .trail.is-demo span:nth-child(#{$i}) {
    opacity: 1 - $i * .13;
    animation: cursor-path $duration $easing infinite;
    animation-delay: -#{$i * 90}ms;
  }
}

07ホバーでリング拡大

.stage:has(.link:hover) .ringという一行だけで、リンクの上にリング状のカーソルがあるかどうかをJavaScriptなしに判定し、scale(1.6)まで広げています。カード型のリンク一覧や、アイコンボタンが並ぶツールバーで、次にどこを押せるかを視覚的に伝えるのに向いています。

:hover 감지transform: scale0.2초
.ring {
  width: 30px;
  height: 30px;
  margin: -15px 0 0 -15px;
  border: 2px solid $color;
  border-radius: 50%;
  transition: transform 180ms $easing;
}

.stage:has(.link:hover) .ring {
  transform: scale(1.6);
}

08デフォルトカーソルを隠す

cursor: noneを指定した領域だけがOSの矢印を消し、そのすき間をpointer-events: noneを付けたカーソル用のdivが埋めるという、他の8つすべてが前提にしている土台の設定です。カスタムカーソルを使うすべてのインタラクティブサイトで、最初に書く一行になります。

cursor: none포인터 기기 감지기반 설정
.zone--default { background: $subject-cream; color: $stage-ink; cursor: auto; }
.zone--custom  { background: $stage-ink; color: $subject-cream; cursor: none; }

.cursor {
  position: absolute;
  top: 0;
  left: 0;
  width: 16px;
  height: 16px;
  margin: -8px 0 0 -8px;
  border-radius: 50%;
  background: $color;
  pointer-events: none;
}

09タッチ端末向け代替処理

@media (pointer: coarse)に一致する端末では、JavaScript側のif (matchMedia('(pointer: fine)').matches)という条件分岐がそもそも実行されず、カスタムカーソルのdivごとdisplay: noneで消えて標準のタッチ操作だけが残ります。ノートPCとタブレットの両方で開かれるハイブリッド端末でも、同じCSSひとつで安全に切り替わります。

pointer: coarse미디어쿼리JS 건너뛰기
.cursor {
  position: absolute;
  top: 0;
  left: 0;
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: $color;
  pointer-events: none;
}

@media (pointer: coarse) {
  .cursor { display: none; }
  .stage { cursor: auto; }
}

どこで崩れるか — 落とし穴

9つとも、動くのはカスタムのdivであって本物のOSカーソルではないので、pointer-events: noneをつけ忘れるとカスタムカーソル自身がクリックを奪ってしまいます。1つ目の落とし穴はまさにこれで、01の雫形にpointer-events: noneを外して試すと、カーソルの下にあるチップがクリックできなくなり、要素は見えているのにリンクだけ反応しない状態になりました。

2つ目は03の磁石ボタンを動かしていて気づいたことです。pointermoveしかイベントを登録していないので、ポインターがステージの外へ一気に抜けると、その後のイベントが一切発生せず、ボタンは引き寄せられた位置のまま止まって見えました。ステージの中でもう一度ポインターを動かせばpullの計算がやり直され位置は戻りますが、外に出たまま操作が終わると戻る保証がありません。修正済みの9ファイルを開くには x65p86j3 という文字列が必要です。下のカードからzipを取ってください。

3つ目は07の.stage:has(.link:hover) .ringを試していたときのことです。MDNの:has()対応表によると、Chrome・Edgeは105以降、Safariは15.4以降、Firefoxは121以降で対応していて、これより古いブラウザではセレクタごと無視されるため、リングは大きくならないだけで他の8つのようにレイアウトが崩れることはありませんでした。

アクセシビリティ

9つともprefers-reduced-motion: reduceで自動再生のanimationを止めますが、止まった後の見た目は項目ごとに違います。01の雫形と02の点・リングは経路の途中の座標で静止したまま残り、06の残像だけはopacity: 0をそろえて尾ごと消してしまいます。09の二枚のパネルはanimation: noneにした上でマウス用パネルのopacityを1、タッチ用パネルのopacityを0に固定し、クロスフェードの途中ではなく片方だけが見える状態で止まります。07のリングが実際のホバーで大きくなるtransition: transform 180ms自体はreduced-motionの対象にしていないので、その環境でも実際にリンクへポインターを重ねれば拡大は動きます。

@media (prefers-reduced-motion: reduce) {
  .cursor.is-demo,
  .dot.is-demo,
  .ring.is-demo,
  .btn.is-demo,
  .label.is-demo,
  .trail.is-demo span,
  .cursor-pos.is-demo,
  .panel.is-demo { animation: none; }

  .trail.is-demo span { opacity: 0; }
  .panel--mouse.is-demo { opacity: 1; }
  .panel--touch.is-demo { opacity: 0; }
}

カーソル以外のCSSアニメーションはこちらのカテゴリー一覧に、このサイトの成り立ちは紹介ページにまとめています。

FAQ

08のcursor: noneを使わずに01〜07だけ使えますか?

使えません。cursor: noneを外すとブラウザ本来の矢印カーソルとカスタムカーソルのdivが同時に表示され、二重のカーソルがずれて動く見た目になります。08で紹介した.stage { cursor: none; }が、残り8つすべての前提になっています。

Reactでは何を使って動きを再現しますか?

zipのreact/フォルダの各コンポーネントは、ここまでと同じクラス名をdivやbutton要素に付けているだけで、追従の速さや拡大縮小の曲線はCSSのtransformanimationが担います。座標を使う01〜07はReact側でもpointermoveのイベントハンドラを持ち、受け取った座標をインラインスタイルのtransformへ渡します。

スマホでもこれらは同じように動きますか?

いいえ。9つとも@media (pointer: coarse)でタッチ端末を検知しており、01〜08のカスタムカーソル要素はdisplay: noneで消え、09では二枚のパネルのうちタッチ用だけが最初から表示されます。指でなぞって動かすカーソルという概念自体がタッチ操作にはないため、これはバグではなく意図した代替です。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。