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

セクション

地球儀をドラッグで回し、ピンに合わせると拠点名が表示される

デモ

操作:ドラッグ・カーソルを重ねる・クリック。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

動作
DOM+WebGL
種類
シーン
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

点で世界地図を描いた地球儀に、6都市の拠点のピンが立っています。ドラッグすると、つかんだ所ごと地球儀が回り、放しても勢いを残してしばらく回ってから止まります。ピンにカーソルを合わせると、その上に拠点名のラベルが表示されます。

地図は、球の上に均等に並べた1万6000個の点を、陸と海に分けて描いたものです。陸か海かの判定には、緯度と経度の2°ごとのマス目を白黒で塗った陸地のビットマップを使っています。これを文字列にしてコードに埋め込み、点ごとの緯度と経度で引きます。陸の点は大きく濃く、海の点は小さく薄くしたので、海の部分にも球の丸みが残ります。

回転は、「正面に来ている経度」と「正面に来ている緯度」の2つの値だけで表しています。横にドラッグしたときは、動かした量を地球儀の半径(px)で割った角度だけ経度を進めます。これで、つかんだ所がポインタに付いてくるように見えます。放したあとの勢いは指数で減らしていて、減り方の係数3を大きくすればすぐに止まり、小さくすれば長く回り続けます。

ピンに当たったかどうかは Raycaster で調べます。当てる相手は、ピンの頭より少し大きい見えない球と、地球儀の球そのものの2つです。ピンだけに当てれば簡単ですが、それでは裏側に回ったピンにも当たり、見えていない拠点のラベルが表示されてしまいます。先に地球儀の球に当たったときは裏側とみなす、という決まりにしました。縁の向こうへ回りかけたピンは頭が球からはみ出して見えるので、薄くしてから消しています。

拠点の一覧は button で書いてあります。押すかキーボードでフォーカスすると、その拠点が近い向きへ1.1秒かけて回って正面に来て、ラベルが表示されます。地球儀の canvas はスクリーンリーダーから外し、拠点名は一覧で伝えています。ピンの位置は一覧のボタンに書いた緯度と経度だけで決まるので、拠点を足したいときは一覧に項目を1つ増やせば済みます。

コード

HTML
<section class="globe" aria-labelledby="globe-title">
  <div class="globe__text">
    <p class="globe__eyebrow">OFFICES</p>
    <h2 class="globe__title" id="globe-title">世界6都市の拠点</h2>
    <p class="globe__lead">ここに説明文が入ります。</p>
    <!-- 拠点の位置は緯度・経度で書く。押すかフォーカスすると、その拠点が地球儀の正面へ回る -->
    <ul class="globe__list">
      <li><button class="globe__site" type="button" data-lat="35.68" data-lon="139.69" data-en="TOKYO">東京</button></li>
      <li><button class="globe__site" type="button" data-lat="34.69" data-lon="135.5" data-en="OSAKA">大阪</button></li>
      <li><button class="globe__site" type="button" data-lat="33.59" data-lon="130.4" data-en="FUKUOKA">福岡</button></li>
      <li><button class="globe__site" type="button" data-lat="25.03" data-lon="121.57" data-en="TAIPEI">台北</button></li>
      <li><button class="globe__site" type="button" data-lat="1.35" data-lon="103.82" data-en="SINGAPORE">シンガポール</button></li>
      <li><button class="globe__site" type="button" data-lat="51.51" data-lon="-0.13" data-en="LONDON">ロンドン</button></li>
    </ul>
  </div>
  <div class="globe__stage">
    <!-- 地球儀は一覧と同じ内容の飾りなので、読み上げからは外す -->
    <canvas class="globe__canvas" aria-hidden="true"></canvas>
    <div class="globe__label" aria-hidden="true"><span class="globe__name"></span><span class="globe__en"></span></div>
  </div>
</section>

CSS
.globe {
  /* 地球儀の色はここで決める。script.js がこの 5 つを読んでシーンへ渡す */
  --face: #171717;      /* 球の面 */
  --dot: #eeece6;       /* 点と経緯線 */
  --pin: #e2948e;       /* ピンの頭(ローズカラー) */
  --pin-hover: #f2bdb7; /* 重ねたときの頭 */
  --stick: #eeece6;     /* ピンの棒 */
  --bg: #111111;
  --surface: #262626;
  --ink: #eeece6;
  --mute: #a8a6a0;
  --line: rgba(238, 236, 230, 0.14);
  display: grid;
  grid-template-columns: minmax(0, 400px) minmax(0, 1fr);
  align-items: center;
  gap: 48px;
  padding: 64px clamp(24px, 7vw, 96px);
  background: var(--bg);
  color: var(--ink);
}

.globe__eyebrow { margin: 0; font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.14em; color: var(--mute); }
.globe__title { margin: 20px 0 0; font-size: clamp(32px, 5vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; word-break: keep-all; }
.globe__lead { margin: 28px 0 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; }

.globe__list { display: grid; grid-template-columns: repeat(2, minmax(0, 180px)); gap: 4px 0; margin: 40px 0 0; padding: 0; list-style: none; }
.globe__site {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 8px 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  line-height: 1;
  letter-spacing: 0.04em;
  white-space: nowrap;
  cursor: pointer;
}
.globe__site::before { content: ""; flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--pin); }
.globe__site:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; border-radius: 2px; }

/* 地球儀は正方形の中に描く。ラベルは canvas の上に重ねる */
.globe__stage { position: relative; width: min(100%, 600px); aspect-ratio: 1; justify-self: center; }
.globe__canvas { display: block; width: 100%; height: 100%; touch-action: pan-y; cursor: grab; }
.globe__canvas.is-grabbing { cursor: grabbing; }
.globe__canvas.is-pin { cursor: pointer; }

.globe__label {
  position: absolute;
  left: 0;
  top: 0;
  display: grid;
  gap: 6px;
  padding: 10px 14px;
  border-radius: 4px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  text-align: center;
  white-space: nowrap;
  visibility: hidden;
  pointer-events: none;
}
.globe__name { font-size: 15px; font-weight: 700; line-height: 1; letter-spacing: 0.04em; }
.globe__en { font-size: 11px; font-weight: 700; line-height: 1; letter-spacing: 0.14em; color: var(--mute); }

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

JavaScript
import { gsap } from "gsap";
import * as THREE from "three";

const RADIUS = 0.45; // 地球儀の半径(canvas の幅に対する比)
const TILT = 20, START = 10; // 正面に来る緯度と、最初に正面に来ている経度(°)。少し上から見て、北半球の拠点を見やすくする
const DOTS = 16000, GRID = 30, HIT = 0.05; // 点の数・経緯線の間隔(°)・ピンの当たりの球の半径(地球儀の半径 = 1)
const LAND = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB/+Af/gAAAAAAAAAAAAAAAAAAAAAAA///////AAHgBAAADgAAAAAAAAAAAAd//////4AB/gAAAAB+AAAAAAAAAAB/G/4////4AAbAAAcAAfwAAAAAAAAADfm/wA///wAAAAADwAf/4AH4AAAAAAH/3/8AP//wAAAAAODD///+DwAAgH8AH/73/wP//gAAA4AMH////////+fv/09f/3/////////8//n///////w+P4AAABAC/D8A////gBNAAgAAAAAAAA3n///////////////////////////+Af/////708D4AIAH//////////////Af/////gHwB4AAAP7////////////4AH4P///gH+AAAAEP4//////////+cAADwB///8H+AAAAGD7/////////8A8AAMAA////v/gAAAPDv/////////4B4AAAABf///v/wAAAbv///////////AwAAAAAP/////wAAAH////////////AgAAAAAH////+8AAAD///////////9AAAAAAAD////+8AAAD///////////9AAAAAAAD/////AAAAB//7/f//////5AAAAAAAH////gAAAAf9vwPP//////zgAAAAAAD////gAAAAfi///v/////+CAAAAAAAD///+AAAAAfCLf/n/////MCAAAAAAAB///8AAAAAf/zf///////mOAAAAAAAB///8AAAAAH/Ai///////G8AAAAAAAAf//4AAAAAf/zB///////hwAAAAAAAAf//wAAAAAf//////////hAAAAAAAAAP/owAAAAA/////f/////gAAAAAAAAAH/AwAAAAB///+/v/////gAAAAAAAAAD/AQAAAAD/////3/////gAAAAAAAAABeA4AAAAD////f/D///+gAAAAAAAAAAfGOAAAAH//////B/z/wAAAAAAAgAAAf+FoAAAD////v+A/h+wgAAAAAAAAAAH8AAAAAH////38A/B/AgAAAAAAAAAAAfgAAAAH/////wAcAfggAAAAAAAAAAAHgAAAAH/////AAcAfgQAAAAAAAAAAABD7AAAD/////wAMAXgYAAAAAAAAAAAA//AAAB/////wAOASAYAAAAAAAAAAAAX/gAAA/////gACAYAcAAAAAAAAAAAAH/8AAAfP///AAAAsHAAAAAAAAAAAAAH/+AAAAB///AAAA8OAAAAAAAAAAAAAP/+AAAAD//8AAAAc+mAAAAAAAAAAAAf//wAAAD//4AAAAM/hgAAAAAAAAAAAf//+AAAB//wAAAAOfi/AAAAAAAAAAAf///AAAA//wAAAAGBwfsAAAAAAAAAAP///gAAA//wAAAAB4AHwAAAAAAAAAAP///gAAA//wAAAAAMoDYIAAAAAAAAAH///AAAAf/4AAAAAAABAAAAAAAAAAAH//+AAAA//4gAAAAAB7AAAAAAAAAAAD//+AAAA//5wAAAAAPzgAA////////+/////////v////////gAAAAAAAAAAAf/8AAAA//DgAAAAA//wAAAAAAAAAAAf/8AAAA//DgAAAAD//4CAAAAAAAAAAf/4AAAAf/HAAAAAH//8AAAAAAAAAAA//gAAAAf/DAAAAAH//+AAAAAAAAAAA//AAAAAf+AAAAAAH//+AAAAAAAAAAA//AAAAAP8AAAAAAH//+AAAAAAAAAAA/+AAAAAH4AAAAAAH//+AAAAAAAAAAA/8AAAAAHwAAAAAAD4f8AAAAAAAAAAA/4AAAAAGAAAAAAADAP8AIAAAAAAAAB/wAAAAAAAAAAAAAAAD4AEAAAAAAAAB/gAAAAAAAAAAAAAAABwAGAAAAAAAAB+AAAAAAAAAAAAAAAAAwAMAAAAAAAAB8AAAAAAAAAAAAAAAAAwAYAAAAAAAAD8AAAAAAAAAAAAAAAAAABwAAAAAAAAD4AAAAAAAAAAAAAAAAAAAgAAAAAAAAD4AAAAAAAAAACAAAAAAAAAAAAAAAAADwgAAAAAAAAAAAAAAAAAAAAAAAAAAADwAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAAAAAB/IB/////gAAAAAAAAAAA+AAAAAAADv/+P//////wAAAAAAAAAD/AAAA//////8////////wAAAAFAP/9/AAAD///////////////gAAB//////+AAAP//////////////+AAB///////gAAP///////////////8AAN//////8AD4////////////////+AAAf//////9H3////////////////4AAAf//////////////////////////gnH///////////////////////////+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; // 陸地のビットマップ(緯度・経度 2° ごと、横 180 × 縦 90 のマスを北西の端から 1 ビットずつ。もとは Natural Earth の陸地)
const SPIN = 3, FADE = { in: 0.2, out: 0.15 }; // 放したあと勢いが減る速さ(1/秒)と、ラベルの出入りの秒数
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const rad = THREE.MathUtils.degToRad;
const onSphere = (lat, lon, r = 1) => new THREE.Vector3(Math.cos(rad(lat)) * Math.sin(rad(lon)) * r, Math.sin(rad(lat)) * r, Math.cos(rad(lat)) * Math.cos(rad(lon)) * r);

document.querySelectorAll(".globe").forEach((section) => {
  const css = (name) => getComputedStyle(section).getPropertyValue(name).trim();
  const canvas = section.querySelector(".globe__canvas"), label = section.querySelector(".globe__label"), sites = [...section.querySelectorAll(".globe__site")];
  const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // 背景は CSS
  const scene = new THREE.Scene(), camera = new THREE.PerspectiveCamera(30, 1, 0.1, 50), globe = new THREE.Group(), parts = [];
  const keep = (...items) => (parts.push(...items), items[0]); scene.add(globe);
  const sphere = new THREE.Mesh(keep(new THREE.SphereGeometry(0.995, 64, 32)), keep(new THREE.MeshBasicMaterial({ color: css("--face") }))); // 裏側の点とピンを隠す
  const bits = Uint8Array.from(atob(LAND), (c) => c.charCodeAt(0)), isLand = (lat, lon) => { const i = Math.min(89, Math.floor((90 - lat) / 2)) * 180 + Math.min(179, Math.floor((lon + 180) / 2)); return (bits[i >> 3] >> (7 - (i & 7))) & 1; };
  const land = [], sea = [], golden = Math.PI * (3 - Math.sqrt(5)), grid = [], deg = THREE.MathUtils.radToDeg;
  for (let i = 0; i < DOTS; i++) { const y = 1 - (2 * (i + 0.5)) / DOTS, r = Math.sqrt(1 - y * y), p = new THREE.Vector3(Math.cos(golden * i) * r, y, Math.sin(golden * i) * r); (isLand(deg(Math.asin(y)), deg(Math.atan2(p.x, p.z))) ? land : sea).push(p); } // 球に均等に並べ(フィボナッチの螺旋)、陸と海に分ける
  for (let lat = -90 + GRID; lat < 90; lat += GRID) for (let lon = 0; lon < 360; lon += 3) grid.push(onSphere(lat, lon, 1.001), onSphere(lat, lon + 3, 1.001));
  for (let lon = 0; lon < 360; lon += GRID) for (let lat = -90; lat < 90; lat += 3) grid.push(onSphere(lat, lon, 1.001), onSphere(lat + 3, lon, 1.001));
  const disc = Object.assign(document.createElement("canvas"), { width: 32, height: 32 }), g = disc.getContext("2d"); g.fillStyle = "#fff"; g.arc(16, 16, 14, 0, Math.PI * 2); g.fill(); // 点を丸く描く
  const map = keep(new THREE.CanvasTexture(disc)), dotsOf = (points, size, opacity) => new THREE.Points(keep(new THREE.BufferGeometry().setFromPoints(points)), keep(new THREE.PointsMaterial({ color: css("--dot"), size, sizeAttenuation: false, map, transparent: true, opacity, depthWrite: false })));
  globe.add(sphere, dotsOf(sea, 1.8, 0.12), dotsOf(land, 3, 0.82), new THREE.LineSegments(keep(new THREE.BufferGeometry().setFromPoints(grid)), keep(new THREE.LineBasicMaterial({ color: css("--dot"), transparent: true, opacity: 0.16 }))));
  // ピン: 細い棒と丸い頭。頭より少し大きい見えない球を当たりにする
  const stick = keep(new THREE.CylinderGeometry(0.005, 0.005, 0.09, 8).translate(0, 0.045, 0)), headShape = keep(new THREE.SphereGeometry(0.022, 20, 12)), hitShape = keep(new THREE.SphereGeometry(HIT, 8, 6));
  const base = new THREE.Color(css("--pin")), hover = new THREE.Color(css("--pin-hover"));
  const pins = sites.map((site, i) => {
    const pin = new THREE.Group(), head = new THREE.Mesh(headShape, keep(new THREE.MeshBasicMaterial({ color: base, transparent: true })));
    const bar = new THREE.Mesh(stick, keep(new THREE.MeshBasicMaterial({ color: css("--stick"), transparent: true }))), hit = new THREE.Mesh(hitShape, keep(new THREE.MeshBasicMaterial({ visible: false })));
    pin.position.copy(onSphere(Number(site.dataset.lat), Number(site.dataset.lon)));
    pin.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), pin.position.clone().normalize());
    head.position.y = hit.position.y = 0.1; hit.userData.site = i; pin.add(bar, head, hit); globe.add(pin);
    return { pin, head, bar, hit, alpha: 1 };
  });
  const view = { L: rad(START), phi: rad(TILT), velocity: 0, shown: -1, alpha: 0 }, raycaster = new THREE.Raycaster(), ndc = new THREE.Vector2(), p = new THREE.Vector3();
  let grab = null, hovered = -1, chosen = -1, turn = null, last = performance.now(), size = 1;
  const pick = (e) => { // ポインタの先のピン。先に球に当たったら、裏側のピンとみなす
    const r = canvas.getBoundingClientRect(); ndc.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1); raycaster.setFromCamera(ndc, camera);
    const hit = raycaster.intersectObjects([sphere, ...pins.map((pin) => pin.hit)], false)[0];
    return hit && hit.object !== sphere && pins[hit.object.userData.site].alpha > 0.5 ? hit.object.userData.site : -1;
  };
  const frame = (ms) => {
    const dt = Math.min(ms - last, 100) / 1000; last = ms; if (!grab && !turn?.isActive()) { view.L += view.velocity * dt; view.velocity *= Math.exp(-SPIN * dt); }
    globe.rotation.set(view.phi, -view.L, 0); globe.updateMatrixWorld(true);
    const horizon = 1 / camera.position.z; // 球の点が見えるのは、カメラ向きの成分がこれより大きいとき。縁の向こうへ回ったピンは薄くして消す
    // ラベル: 重ねているピン(無ければ一覧で選んだ拠点)へ。替わるときは、前のラベルを消しきってから出す
    const want = grab ? -1 : hovered >= 0 ? hovered : chosen;
    if (view.shown !== want && view.alpha > 0) view.alpha = reduceMotion ? 0 : Math.max(0, view.alpha - dt / FADE.out);
    else { view.shown = want; view.alpha = want < 0 ? 0 : reduceMotion ? 1 : Math.min(1, view.alpha + dt / FADE.in); }
    const a = 1 - (1 - view.alpha) ** 2;
    pins.forEach((item, i) => {
      item.alpha = THREE.MathUtils.smoothstep(item.pin.getWorldPosition(p).z, horizon - 0.08, horizon + 0.06);
      item.pin.visible = item.alpha > 0; item.head.material.opacity = item.alpha; item.bar.material.opacity = 0.7 * item.alpha;
      item.head.scale.setScalar(1 + 0.35 * (i === view.shown ? a : 0)); item.head.material.color.lerpColors(base, hover, i === view.shown ? a : 0);
    });
    label.style.visibility = view.shown >= 0 && a > 0 ? "inherit" : "hidden";
    if (view.shown >= 0) {
      const site = sites[view.shown]; pins[view.shown].head.getWorldPosition(p).project(camera);
      if (label.firstElementChild.textContent !== site.textContent) { label.firstElementChild.textContent = site.textContent; label.lastElementChild.textContent = site.dataset.en; }
      label.style.opacity = String(a);
      label.style.transform = `translate(${Math.round(((p.x + 1) / 2) * size)}px, ${Math.round(((1 - p.y) / 2) * size - 16 + 6 * (1 - a))}px) translate(-50%, -100%)`;
    }
    renderer.render(scene, camera);
  };
  canvas.addEventListener("pointerdown", (e) => {
    if (pick(e) < 0 && raycaster.intersectObject(sphere, false).length === 0) return; // 地球儀の外では回さない
    turn?.kill(); chosen = -1; view.velocity = 0; grab = { x: e.clientX, y: e.clientY, time: e.timeStamp }; canvas.setPointerCapture(e.pointerId); canvas.classList.add("is-grabbing");
  });
  canvas.addEventListener("pointermove", (e) => {
    if (!grab) { hovered = pick(e); if (hovered >= 0) chosen = -1; canvas.classList.toggle("is-pin", hovered >= 0); return; }
    const radius = RADIUS * canvas.clientWidth, dL = -(e.clientX - grab.x) / radius; // 横の移動量 ÷ 半径(px)だけ回すと、つかんだ所がポインタに付いてくる
    view.L += dL; view.phi = gsap.utils.clamp(rad(-10), rad(60), view.phi + (e.clientY - grab.y) / radius);
    view.velocity = reduceMotion ? 0 : dL / Math.max((e.timeStamp - grab.time) / 1000, 0.008); // 放したときに残す勢い(ラジアン毎秒)
    Object.assign(grab, { x: e.clientX, y: e.clientY, time: e.timeStamp });
  });
  const release = (e) => { if (grab && e.timeStamp - grab.time > 80) view.velocity = 0; grab = null; canvas.classList.remove("is-grabbing"); }; // 止めてから放したら、勢いは残さない
  canvas.addEventListener("pointerup", release); canvas.addEventListener("pointercancel", release); canvas.addEventListener("pointerleave", () => { if (!grab) hovered = -1; });
  // 一覧の拠点名を押すかフォーカスすると、その拠点が正面へ回ってラベルが表示される(近い向きへ回す)
  const choose = (i) => {
    const site = sites[i], target = rad(Number(site.dataset.lon)), turnBy = ((((target - view.L + Math.PI) % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2)) - Math.PI;
    turn?.kill(); chosen = i; view.velocity = 0;
    turn = gsap.to(view, { L: view.L + turnBy, phi: rad(gsap.utils.clamp(-10, 45, Number(site.dataset.lat))), duration: reduceMotion ? 0 : 1.1, ease: "power3.inOut" });
  };
  sites.forEach((site, i) => { site.addEventListener("click", () => choose(i)); site.addEventListener("focus", () => choose(i)); });
  const sizes = new ResizeObserver(() => { // 半径 1 の球が RADIUS × 幅 に見える距離へカメラを置く
    size = canvas.clientWidth; renderer.setSize(size, size, false);
    const tanA = (RADIUS * size * Math.tan(rad(15))) / (size / 2); camera.position.z = Math.sqrt(1 / (tanA * tanA) + 1); camera.updateProjectionMatrix();
  });
  const visible = new IntersectionObserver(([e]) => { last = performance.now(); renderer.setAnimationLoop(e.isIntersecting ? frame : null); }); // 画面の外では描かない
  sizes.observe(canvas); visible.observe(canvas);
  section.destroy = () => { // PJAX でセクションごと破棄するときに呼ぶ。放っておくと WebGL のコンテキストが積み上がる
    renderer.setAnimationLoop(null); sizes.disconnect(); visible.disconnect(); turn?.kill();
    parts.forEach((part) => part.dispose()); renderer.dispose(); renderer.forceContextLoss();
  };
});

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

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