本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
カーソル

カーソルを光源にして、並んだ積み木の影の向きと長さが変わる

デモ

操作:カーソルを動かす。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

用途
カーソル
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

平らな地に並べた角柱と円柱を上から見た図にして、カーソルの位置を光源に影を落とします。点光源の投影では、高さ h の上面の点は、地の上の「光源 +(点 − 光源)× 光源の高さ ÷(光源の高さ − h)」に落ちます。底面の輪郭の点と、投影した上面の点をまとめて凸包を取ると 1 つの影の形になるので、それを SVG の polygon に書き込みます。光源から遠い積み木ほど影が長く伸びるのは、この式のとおりです。

光源の高さは 400、積み木の高さは 44〜110 です。影の濃さは、光源と積み木の中心の距離に合わせて 0.56 から 0.12 まで下げ、距離 640 でいちばん淡くします。影の縁は、光源を半径 6 の円の 6 点に分けて影を 6 枚重ね、それぞれを 6 分の 1 の濃さで描いてぼかします。遠い影ほど縁が広くぼけて本物の影に近く見え、ぼかしのフィルターより軽く済みます。光源は quickTo で 0.35 秒かけてポインタに追いつきます。

上面の形を光源と反対へ平行にずらしただけの影にすると、光源が近いときに影が積み木から離れて見えます。点光源では上面の影が底面より大きく広がるので、投影の倍率を掛けてから底面と合わせて囲みます。光源の高さを積み木より低くすると倍率が負になって影が裏返るので、いちばん高い積み木より十分高くしてください。地の明るみは CSS の radial-gradient で光源の位置に敷くので、同じページに何枚置いても SVG の id がぶつかりません。

調整できるのは、光源の高さ(低いほど影が長い)、濃さの幅と届く距離、縁をぼかす点の数と半径、積み木の配置と高さ(data-h)です。層がずれて奥行きを出す演出や、近いドットを光らせる背景と違い、カーソルが光そのものになって影の向きと長さで答えるので、置いた物の立体が伝わります。ポインタが地の外へ出ると、光源は左上の外へ戻り、影は右下へそろいます。指で触る端末では触れた所へ光を置き、動きを減らす設定では遅れずに光を置きます。家具や雑貨、建築、プロダクトの紹介に向いています。

コード

HTML
<section class="light-shadow" aria-labelledby="light-shadow-title">
  <div class="light-shadow__copy">
    <p class="light-shadow__label">LIGHT & SHADOW</p>
    <h2 class="light-shadow__title" id="light-shadow-title">光を動かすと、<br>影が答える</h2>
    <p class="light-shadow__lead">カーソルの位置が光になります。<br>近い積み木の影は短く濃く、<br>遠い積み木の影は長く淡く伸びます。</p>
  </div>
  <div class="light-shadow__field">
    <!-- 積み木は rect(角柱)と circle(円柱)。data-h が高さ(viewBox の単位)、data-tone が色 -->
    <svg viewBox="0 0 664 528" role="img" aria-label="12 個の積み木を上から見た図。カーソルの位置を光源にして影が落ちる">
      <g class="light-shadow__shadows"></g>
      <g class="light-shadow__blocks">
        <rect x="68" y="68" width="72" height="72" rx="3" data-h="70" data-tone="slate" />
        <circle cx="262" cy="96" r="32" data-h="96" data-tone="greige" />
        <rect x="360" y="84" width="120" height="56" rx="3" data-h="48" data-tone="sage" />
        <circle cx="568" cy="100" r="28" data-h="60" data-tone="rose" />
        <circle cx="96" cy="268" r="36" data-h="50" data-tone="lavender" />
        <rect x="212" y="222" width="80" height="80" rx="3" data-h="110" data-tone="olive" />
        <circle cx="414" cy="272" r="30" data-h="80" data-tone="slate" />
        <rect x="534" y="226" width="64" height="64" rx="3" data-h="44" data-tone="greige" />
        <rect x="80" y="372" width="56" height="104" rx="3" data-h="64" data-tone="sage" />
        <circle cx="258" cy="430" r="28" data-h="70" data-tone="rose" />
        <rect x="380" y="384" width="72" height="72" rx="3" data-h="90" data-tone="lavender" />
        <circle cx="566" cy="428" r="36" data-h="54" data-tone="olive" />
      </g>
      <circle class="light-shadow__dot" r="6" />
    </svg>
  </div>
</section>

CSS
.light-shadow {
  display: grid;
  grid-template-columns: minmax(0, 360px) minmax(0, 1fr);
  align-items: center;
  gap: 64px;
  max-width: 1088px;
  margin: 0 auto;
  padding: 96px 16px;
  color: #262626;
}

.light-shadow__label {
  margin: 0;
  color: #6b6b6b;
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.14em;
}

.light-shadow__title {
  margin: 20px 0 0;
  font-size: clamp(32px, 5vw, 44px);
  font-weight: 800;
  line-height: 1.1;
  letter-spacing: -0.02em;
}

.light-shadow__lead {
  margin: 24px 0 0;
  font-size: 18px;
  line-height: 1.8;
  letter-spacing: 0.04em;
}

.light-shadow__field {
  /* 光源の位置(スクリプトが書く)と、地の明るみ(光源の真下・遠く) */
  --lx: -7%;
  --ly: -8%;
  --glow: #fbfaf6;
  --ground: #e4e2db;
  --shadow: #465c6a;
  aspect-ratio: 664 / 528;
  overflow: clip;
  border-radius: 4px;
  /* 光のにじみ(半径 36)と明るみ(半径 640)。どちらも viewBox の幅・高さに対する比の楕円で、画面では円になる */
  background:
    radial-gradient(ellipse 5.42% 6.82% at var(--lx) var(--ly), rgb(255 255 255 / 0.9), rgb(255 255 255 / 0)),
    radial-gradient(ellipse 96.4% 121.2% at var(--lx) var(--ly), var(--glow), var(--ground));
}

.light-shadow__field svg {
  display: block;
  width: 100%;
  height: 100%;
}

.light-shadow__shadows {
  fill: var(--shadow);
}

.light-shadow__blocks > * {
  stroke-width: 1;
}

/* 積み木の上面の色と縁の色 */
[data-tone="slate"] { fill: #79b6e3; stroke: #3b87c4; }
[data-tone="sage"] { fill: #8ddc87; stroke: #48be4e; }
[data-tone="greige"] { fill: #f1bc7a; stroke: #cf8c3a; }
[data-tone="rose"] { fill: #f17f78; stroke: #dc5547; }
[data-tone="lavender"] { fill: #a689e6; stroke: #7258c8; }
[data-tone="olive"] { fill: #ecdb5f; stroke: #bdac28; }

.light-shadow__dot {
  fill: #fff;
  stroke: #262626;
  stroke-width: 1.5;
  opacity: 0;
}

/* 光源の点がカーソルの代わりになるので、ポインタのある端末では本物のカーソルを隠す */
@media (hover: hover) and (pointer: fine) {
  .light-shadow__field.is-live {
    cursor: none;
  }
}

@media (max-width: 860px) {
  .light-shadow {
    grid-template-columns: minmax(0, 1fr);
    gap: 40px;
  }
}

JavaScript
import { gsap } from "gsap";

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const NS = "http://www.w3.org/2000/svg";
const LIGHT = 400; // 光源の高さ(viewBox の単位)。低くするほど影が長くなる
const REACH = 640; // この距離で影がいちばん淡くなる
const ALPHA = [0.56, 0.12]; // 影の濃さ(光源の真下・REACH より遠い)
const REST = { x: -48, y: -40 }; // ポインタが地の外にいるときの光源(地の左上の外)
// 光源を半径 6 の円の 6 点に分け、影を 6 枚重ねて縁をぼかす(ぼかしのフィルターより軽い)
const SPREAD = Array.from({ length: 6 }, (_, i) => [6 * Math.cos((i / 6) * Math.PI * 2), 6 * Math.sin((i / 6) * Math.PI * 2)]);
const clamp01 = gsap.utils.clamp(0, 1);

// 点の列を囲む凸包(モノトーンチェーン)
function hull(points) {
  const sorted = [...points].sort((a, b) => a.x - b.x || a.y - b.y);
  const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
  const half = (list) =>
    list.reduce((out, q) => {
      while (out.length >= 2 && cross(out[out.length - 2], out[out.length - 1], q) <= 0) out.pop();
      out.push(q);
      return out;
    }, []);
  const lower = half(sorted);
  const upper = half(sorted.reverse());
  return lower.slice(0, -1).concat(upper.slice(0, -1));
}

document.querySelectorAll(".light-shadow__field").forEach((field) => {
  const svg = field.querySelector("svg");
  const layer = svg.querySelector(".light-shadow__shadows");
  const dot = svg.querySelector(".light-shadow__dot");
  const { width: W, height: H } = svg.viewBox.baseVal;

  // 積み木ごとに、底面の輪郭の点(角柱は 4 隅、円柱は 32 点)と影の polygon を用意する
  const blocks = [...svg.querySelectorAll("[data-h]")].map((shape) => {
    const num = (name) => Number(shape.getAttribute(name));
    let outline;
    if (shape.tagName === "circle") {
      const [cx, cy, r] = [num("cx"), num("cy"), num("r")];
      outline = Array.from({ length: 32 }, (_, i) => ({ x: cx + r * Math.cos((i / 32) * Math.PI * 2), y: cy + r * Math.sin((i / 32) * Math.PI * 2) }));
    } else {
      const [x, y, w, h] = [num("x"), num("y"), num("width"), num("height")];
      outline = [{ x, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x, y: y + h }];
    }
    const center = outline.reduce((c, p) => ({ x: c.x + p.x / outline.length, y: c.y + p.y / outline.length }), { x: 0, y: 0 });
    const shadows = SPREAD.map(() => layer.appendChild(document.createElementNS(NS, "polygon")));
    return { h: Number(shape.dataset.h), outline, center, shadows };
  });

  const light = { ...REST };
  // 高さ h の上面の点 P は、光源 L から見て地の上の L + (P − L) × LIGHT / (LIGHT − h) に落ちる。底面と合わせた凸包が影
  const render = () => {
    for (const b of blocks) {
      const k = LIGHT / (LIGHT - b.h);
      const d = Math.hypot(b.center.x - light.x, b.center.y - light.y);
      const alpha = (ALPHA[0] + (ALPHA[1] - ALPHA[0]) * clamp01(d / REACH)) / SPREAD.length;
      b.shadows.forEach((poly, i) => {
        const lx = light.x + SPREAD[i][0];
        const ly = light.y + SPREAD[i][1];
        const top = b.outline.map((p) => ({ x: lx + (p.x - lx) * k, y: ly + (p.y - ly) * k }));
        poly.setAttribute("points", hull([...b.outline, ...top]).map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" "));
        poly.setAttribute("fill-opacity", alpha.toFixed(3));
      });
    }
    field.style.setProperty("--lx", `${(light.x / W) * 100}%`);
    field.style.setProperty("--ly", `${(light.y / H) * 100}%`);
    // 光源の点は地の中にいるあいだだけ見せる(縁から 24 で薄れる)
    dot.setAttribute("transform", `translate(${light.x.toFixed(1)} ${light.y.toFixed(1)})`);
    dot.style.opacity = clamp01(Math.min(light.x, light.y, W - light.x, H - light.y) / 24);
  };

  // 動きを減らす設定では遅れずに置く(quickTo は duration が 0 だと値が動かない)
  const follow = (prop) => (reduceMotion ? (value) => ((light[prop] = value), render()) : gsap.quickTo(light, prop, { duration: 0.35, ease: "power3", onUpdate: render }));
  const toX = follow("x");
  const toY = follow("y");
  const aim = (event) => {
    const r = svg.getBoundingClientRect();
    toX(((event.clientX - r.left) / r.width) * W);
    toY(((event.clientY - r.top) / r.height) * H);
  };
  field.addEventListener("pointermove", aim);
  field.addEventListener("pointerdown", aim); // 指で触る端末では、触れた所へ光を置く
  field.addEventListener("pointerleave", (event) => {
    if (event.pointerType === "touch") return; // 指を離しても光はその場に残す
    toX(REST.x);
    toY(REST.y);
  });
  field.classList.add("is-live");
  render();
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。