Reactで、画像の上の点を押すと説明が出る表示を作る方法

​​React​CSS,Playwright,TypeScript,アクセシビリティ,コンテナクエリ

画像の上の点を押して説明を出すには、画像を position: relative の箱で包み、点のボタンを left と top の % で重ねます。どの点が開いているかは、点の id を1つだけ持つ state で管理します。説明の幅は、コンテナークエリーの単位で画像の幅の半分より狭くしておくと、スマートフォンの幅でも画像の横からはみ出しません。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作して確かめました。画像は、この記事のために描いた机のイラスト(SVG)です。Firefox と Safari、スマートフォンの実機、実際のスクリーンリーダーでは試していません。

机のイラストの上にある番号付きの点を、2、3、4 の順に押す。押した点がピンクになり、その横に白い枠の説明が出る。次の点を押すと、前の説明は閉じる
点を3つ順に押したときの動き(上の「クリック 1回目/3回」の表示、マウスの絵、押した瞬間に点が少し縮む動きは GIF 用に足したもの)

点の位置は、画像の幅と高さに対する % で持つ

点の位置をピクセルで持つと、画像が縮んでも点は同じピクセルの場所に置かれるので、絵の中の場所からずれます。そこで、画像の左上から何 % の場所かで持ちます。元の画像が 720×450 で、モニターの画面の真ん中あたり(左から 360px、上から 135px)に点を置くなら、x: 50、y: 30 です(360 ÷ 720 = 50%、135 ÷ 450 = 30%)。

tsx
const spots: Spot[] = [
  { id: 'plant', x: 12, y: 38, title: '観葉植物', body: '机の左の端に置いた、小さな鉢植えです。' },
  { id: 'monitor', x: 50, y: 30, title: 'モニター', body: '机の真ん中に置いた画面です。' },
  { id: 'mug', x: 84, y: 68, title: 'マグカップ', body: '右の手前に置いた、ふた付きのカップです。' },
  { id: 'keyboard', x: 46, y: 80, title: 'キーボード', body: 'モニターの手前に置いた、幅の狭いキーボードです。' },
];

title は点のボタンの名前と説明の見出しに、body は説明の本文に使います。

コンポーネントの全体

openId に、開いている点の id を1つだけ入れます。別の点を押すと openId が置き換わるので、前の説明は自動で閉じます。同じ点をもう一度押したときは null に戻します。

tsx
import { useEffect, useId, useRef, useState } from 'react';

export type Spot = {
  id: string;
  x: number; // 画像の幅に対する横の位置(%)
  y: number; // 画像の高さに対する縦の位置(%)
  title: string;
  body: string;
};

type Props = { src: string; alt: string; spots: Spot[] };

export function ImageHotspots({ src, alt, spots }: Props) {
  const [openId, setOpenId] = useState<string | null>(null);
  const rootRef = useRef<HTMLDivElement>(null);
  const baseId = useId();

  // 開いている間だけ、Esc キーと外側のクリックを待つ
  useEffect(() => {
    if (openId === null) return;
    const openSpot = () => rootRef.current?.querySelector<HTMLElement>(`[data-spot="${openId}"]`);
    const onKeyDown = (e: KeyboardEvent) => {
      if (e.key !== 'Escape') return;
      openSpot()?.querySelector('button')?.focus(); // 開いていた点のボタンにフォーカスを戻す
      setOpenId(null);
    };
    const onPointerDown = (e: PointerEvent) => {
      // 開いている点(ボタンと説明)の外を押したら閉じる
      if (!openSpot()?.contains(e.target as Node)) setOpenId(null);
    };
    document.addEventListener('keydown', onKeyDown);
    document.addEventListener('pointerdown', onPointerDown);
    return () => {
      document.removeEventListener('keydown', onKeyDown);
      document.removeEventListener('pointerdown', onPointerDown);
    };
  }, [openId]);

  return (
    <div className="hotspots" ref={rootRef}>
      <img src={src} alt={alt} />
      {spots.map((spot, i) => {
        const open = spot.id === openId;
        const panelId = `${baseId}-${spot.id}`;
        // 右半分の点は説明を左に、下半分の点は説明を上に出す
        const side = `${spot.x > 50 ? 'left' : 'right'} ${spot.y > 50 ? 'above' : 'below'}`;
        return (
          <div
            key={spot.id}
            className="spot"
            data-spot={spot.id}
            style={{ left: `${spot.x}%`, top: `${spot.y}%`, zIndex: open ? 2 : 1 }}
          >
            <button
              type="button"
              className="spot-button"
              aria-expanded={open}
              aria-controls={panelId}
              onClick={() => setOpenId(open ? null : spot.id)}
            >
              <span aria-hidden="true">{i + 1}</span>
              <span className="visually-hidden">{spot.title}</span>
            </button>
            <div id={panelId} className={`spot-panel ${side}`} hidden={!open}>
              <strong>{spot.title}</strong>
              <p>{spot.body}</p>
            </div>
          </div>
        );
      })}
    </div>
  );
}

点のボタンには、aria-expanded で開いているかどうかを、aria-controls でどの説明を開くかを持たせます。説明の id は、useId で作った値と点の id をつなげて作ります。同じコンポーネントをページに2つ置いても、id がぶつかりません。

説明は、閉じている間も消さずに hidden 属性で隠しています。こうすると aria-controls の指す要素がいつもページにあります。hidden 属性の付いた要素は、ブラウザーが描画しません。

点の番号(1、2…)は aria-hidden にして、ボタンの名前には見えない span に入れた title を使います。後で示すアクセシビリティツリーでも、ボタンの名前は「1」ではなく「モニター」になっています。

使うときは、画像の src と、画像の代わりのテキスト(alt)と、点の配列(spots)を渡します。

tsx
createRoot(document.getElementById('root')!).render(
  <ImageHotspots src="desk.svg" alt="作業机のイラスト。モニター、キーボード、マグカップ、観葉植物が置いてある" spots={spots} />,
);

CSS で、点を画像の上に重ねる

css
.hotspots { position: relative; max-width: 720px; container-type: inline-size; }
.hotspots img { display: block; width: 100%; height: auto; }
.spot { position: absolute; transform: translate(-50%, -50%); }
.spot-button {
  width: 32px; height: 32px; border-radius: 50%; border: 2px solid #fff;
  background: #067a98; color: #fff; font: 700 16px/1 sans-serif; cursor: pointer;
  box-shadow: 0 1px 4px rgb(0 0 0 / 0.4);
}
.spot-button[aria-expanded='true'] { background: #d85878; }
.spot-button:focus-visible { outline: 3px solid #1f2a30; outline-offset: 2px; }
.spot-panel {
  position: absolute; width: min(240px, 50cqw - 28px); box-sizing: border-box;
  padding: 10px 12px; background: #fff; border: 2px solid #00c8e8; border-radius: 8px;
  color: #1f2a30; font-size: 14px; line-height: 1.6;
}
.spot-panel p { margin: 4px 0 0; }
.spot-panel.right { left: calc(100% + 8px); }
.spot-panel.left { right: calc(100% + 8px); }
.spot-panel.below { top: 0; }
.spot-panel.above { bottom: 0; }
.visually-hidden {
  position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

.spot は点の中心が指定した位置に来るように、translate(-50%, -50%) で自分の大きさの半分だけ左上へ戻しています。開いている点は、ボタンの色を #d85878(ピンク)に変えて、どれが開いているかを見て分かるようにしています。

説明を出す向きは、コンポーネントの中で点の位置から決めています。画像の右半分にある点(x が 50 より大きい)は説明を点の左に、下半分にある点(y が 50 より大きい)は説明を上に出します。開いている点の zIndex を 2 にしているのは、説明がほかの点の下に潜らないようにするためです。後で載せる、画面の幅 360px のスクリーンショットでは、モニターの説明がマグカップの点(3)の上に重なっています。

押したとき、閉じるときの動き

Chromium で点を押していき、点ごとに aria-expanded の値と、説明が見えているかを読み取りました。

text
[最初] plant:false/隠れている monitor:false/隠れている mug:false/隠れている keyboard:false/隠れている
[モニターを押す] plant:false/隠れている monitor:true/見えている mug:false/隠れている keyboard:false/隠れている
[マグカップを押す] plant:false/隠れている monitor:false/隠れている mug:true/見えている keyboard:false/隠れている
[マグカップをもう一度押す] plant:false/隠れている monitor:false/隠れている mug:false/隠れている keyboard:false/隠れている
[観葉植物を開いて、説明の中を押す] plant:true/見えている monitor:false/隠れている mug:false/隠れている keyboard:false/隠れている
[画像の点のない場所を押す] plant:false/隠れている monitor:false/隠れている mug:false/隠れている keyboard:false/隠れている
[観葉植物を開いて、画像の外の文章を押す] plant:false/隠れている monitor:false/隠れている mug:false/隠れている keyboard:false/隠れている

別の点を押すと前の説明が閉じ、同じ点をもう一度押すと閉じます。説明の中を押したときは閉じません。

閉じる処理は、説明が開いている間だけ document に pointerdown と keydown のリスナーを付けて行っています。押された場所が「開いている点(ボタンと説明を包む .spot)」の外なら閉じます。画像の、点のない場所を押したときも閉じます。

コードの上では、開いている点のボタンを押したときは .spot の内側なので pointerdown では閉じず、続く click で openId が null になります。別の点を押したときは、pointerdown で一度 null になり、その後の click で新しい点が開きます(この順番そのものは記録していません。確かめたのは、上の操作の後の状態です)。

キーボードと読み上げ

点は button なので、Tab キーでフォーカスでき、Enter キーと Space キーで押せます。Esc キーで閉じたときは、開いていた点のボタンにフォーカスを移すようにしています。

text
[Tab を2回] フォーカス: monitor
[Enter] plant:false/隠れている monitor:true/見えている mug:false/隠れている keyboard:false/隠れている
[Esc] plant:false/隠れている monitor:false/隠れている mug:false/隠れている keyboard:false/隠れている / フォーカス: monitor
[Space で開いて Tab] フォーカス: mug / plant:false/隠れている monitor:true/見えている mug:false/隠れている keyboard:false/隠れている

Tab キーを2回押すと、2 番の点(モニター)にフォーカスが移り、Enter キーで説明が開きました。Esc キーで閉じた後も、フォーカスはモニターの点にあります。続けて Space キーを押すと、もう一度開きました。一方、開いたまま Tab キーで次の点へ移っても、説明は閉じません。フォーカスが離れたら閉じたい場合は、別に focusout などで閉じる処理を足す必要があります。

モニターの点を開いたときのアクセシビリティツリー(Playwright の ariaSnapshot で取得)は、次のとおりです。

text
- img "作業机のイラスト。モニター、キーボード、マグカップ、観葉植物が置いてある"
- button "観葉植物"
- button "モニター" [expanded]
- strong: モニター
- paragraph: 机の真ん中に置いた画面です。
- button "マグカップ"
- button "キーボード"

ボタンの名前が点の番号ではなく「モニター」になり、開いているボタンにだけ expanded が付いています。閉じている説明は、ツリーに出ていません。

このボタンと説明の組み合わせは、W3C の ARIA Authoring Practices Guide にある、ディスクロージャー(開閉する内容)のパターンと同じ形です。このパターンでは、内容が見えているときにボタンの aria-expanded を true にします。aria-controls で開く内容を指すこともできます。MDN の aria-expanded の解説では、表示を切り替えるボタンに aria-expanded と aria-controls を付ける例が示されています。

狭い画面でも、説明を画像の内側に収める

画面の幅を 760px と 360px にして、点の中心が画像のどこにあるかと、説明の上下左右の端(画像の左上からの距離、px)を測りました。360px のときは、ページの余白があるので画像の幅は 328px です。

text
[画面の幅 760px] 画像 720×450px
  plant: 点の中心 (12.0%, 38.0%) 説明 左110.4 右350.4 上155.0 下250.2 幅240.0 → 横は画像の内側、縦は画像の内側
  monitor: 点の中心 (50.0%, 30.0%) 説明 左384.0 右624.0 上119.0 下191.8 幅240.0 → 横は画像の内側、縦は画像の内側
  mug: 点の中心 (84.0%, 68.0%) 説明 左340.8 右580.8 上226.8 下322.0 幅240.0 → 横は画像の内側、縦は画像の内側
  keyboard: 点の中心 (46.0%, 80.0%) 説明 左355.2 右595.2 上280.8 下376.0 幅240.0 → 横は画像の内側、縦は画像の内側
[画面の幅 360px] 画像 328×205px
  plant: 点の中心 (12.0%, 38.0%) 説明 左63.4 右199.4 上61.9 下179.5 幅136.0 → 横は画像の内側、縦は画像の内側
  (観葉植物を開いたときのページの幅/画面の幅 360/360)
  monitor: 点の中心 (50.0%, 30.0%) 説明 左188.0 右324.0 上45.5 下140.7 幅136.0 → 横は画像の内側、縦は画像の内側
  mug: 点の中心 (84.0%, 68.0%) 説明 左115.5 右251.5 上37.8 下155.4 幅136.0 → 横は画像の内側、縦は画像の内側
  keyboard: 点の中心 (46.0%, 80.0%) 説明 左174.9 右310.9 上40.0 下180.0 幅136.0 → 横は画像の内側、縦は画像の内側

画像が 720px でも 328px でも、点の中心は指定した % の場所にあります。説明の右の端は、どの点も画像の幅(720px、328px)を超えていません。上と下の端も、どの点も 0 から画像の高さ(450px、205px)の間に収まりました。

縦の向きは、点が上半分なら下に、下半分なら上に出すだけで、はみ出しを測って直す処理は入れていません。今回の説明の長さでは収まりましたが、説明の文が長く画像の縦が短いときに画像の上下の外へ出ないかは、この作りでは確かめていません。

説明の幅は min(240px, 50cqw - 28px) にしています。cqw は、container-type: inline-size を指定した箱(ここでは .hotspots)の幅の 1% を表す単位で、MDN のコンテナークエリーの解説では「コンテナークエリーの長さ単位」として紹介されています。

左半分の点は説明を右に出すので、点の中心から画像の右の端までは、少なくとも画像の幅の半分(50cqw)あります。説明はボタンの右の端から 8px 離して置くので、点の中心から見ると、半径の 16px とすき間の 8px で、合わせて 24px 右から始まります。これを引くと、使える幅は 50cqw - 24px です。そこからさらに 4px 引いて、説明の枠が画像の右の端にぴったり付かないように余白を残したのが 50cqw - 28px です。画像の幅が 328px なら 164 − 28 = 136px で、測った幅と同じです。右半分の点は説明を左に出すので、同じ計算で左の端に収まります。

画面の幅 360px で、モニターの点を開いた画面。点はモニターの画面の上にあり、説明は点の右に、画像の右の端より内側に出ている
説明の幅を min(240px, 50cqw – 28px) にして、画面の幅 360px でモニターの点を開いたところ

比べるために、説明の幅を 240px に固定して、画面の幅 360px で同じように測りました。

text
[比較:説明の幅を 240px に固定、画面の幅 360px]
  plant: 説明 左63.4 右303.4 → 画像の内側(ページの幅/画面の幅 360/360)
  monitor: 説明 左188.0 右428.0 → はみ出す(ページの幅/画面の幅 444/360)
  mug: 説明 左11.5 右251.5 → 画像の内側(ページの幅/画面の幅 360/360)
  keyboard: 説明 左174.9 右414.9 → はみ出す(ページの幅/画面の幅 431/360)

真ん中のモニターと、その下のキーボードの説明が画像の右からはみ出し、ページ全体の幅が画面の幅より広くなりました(モニターのとき 444px)。横にスクロールできてしまう状態です。

説明の幅を画像の幅の半分から決めておくと、画像の幅に合わせて説明も狭くなり、左右のはみ出しを防げます。

​Posted by 86