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

カメラのシャッターのアイコンで、羽根が回りながら閉じて開く

デモ

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

仕様

用途
アイコン
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

絞りのアイコンは、外の輪と、丸い面から「六角形の穴」と「穴の辺を延ばした 6 本の隙間」を抜いた羽根でできています。シャッターのボタンを押すと、6 枚の羽根が回りながら中心へ閉じ、すぐに開き直します。閉じきった瞬間に、カメラの映像に白い幕を重ねて光らせます。

閉じるのは 0.16 秒(power2.in)で、0.06 秒止まってから 0.45 秒(power2.out)で開きます。閉じるときと開くときに 30° ずつ回るので、1 回撮ると模様は 60° 進みます。六角形は 60° 回すと元と同じ形なので、何回撮っても同じ見た目に戻ります。光は閉じきる直前の 0.04 秒で白の不透明度 0.6 まで上げ、0.55 秒かけて消します。

羽根の形は、穴の大きさと角度から 6 つの頂点を計算し、穴と 6 本の隙間を 1 本の線にまとめて、mask の中で黒く塗って抜きます。隙間は、頂点から辺の向きへ延ばした線です。穴を小さくすると隙間が中心へ寄っていき、0 になると中心から出る 6 本の線になって、閉じた絞りに見えます。切り抜きは id で指すので、同じページに 2 つ置くと、後の部品が先の部品の形を使ってしまいます。id を部品ごとに付け直します。

角度は「撮った回数 × 60°」で決めます。今の角度に足す書き方だと、続けて押したとき半端な角度で止まります。撮れたことは、見えない role="status" の要素に「3枚目を撮りました」のように書いて、読み上げに届けます。動きを減らす設定では羽根を動かさず、弱い光だけで知らせます。

閉じる速さ・回る角度・光の濃さで印象が変わります。白を強くしすぎると、点滅が苦手な人の負担になるので、半分ほどの濃さにとどめています。このデモはボタンを 5 倍に拡大して見せています。カメラで撮る画面や、プロフィール写真・書類を撮る画面のシャッターに向いています。

コード

HTML
<!-- 映像のプレビューと、撮るボタン。光る幕はプレビューの上に重ねる -->
<div class="camera">
  <div class="camera__view">
    <!-- ここにカメラの映像(video)を置く。背景のグラデーションは映像の代わりの仮の色 -->
    <div class="camera__flash" aria-hidden="true"></div>
  </div>
  <button class="camera__shutter" type="button" aria-label="写真を撮る">
    <svg class="camera__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
      <defs>
        <!-- 羽根の面から、六角形の穴と 6 本の隙間を抜く。形は script.js が描く(id は部品ごとに付け直す) -->
        <mask id="camera-iris" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
          <rect width="24" height="24" fill="#fff" />
          <path class="camera__cut" fill="#000" stroke="#000" stroke-width="0.9" stroke-linecap="round" stroke-linejoin="round" />
        </mask>
      </defs>
      <g class="camera__body">
        <circle class="camera__ring" cx="12" cy="12" r="10" />
        <circle class="camera__blades" cx="12" cy="12" r="7.5" mask="url(#camera-iris)" />
      </g>
    </svg>
  </button>
  <!-- 撮れたことを読み上げに伝える(見た目には出さない) -->
  <p class="camera__status" role="status"></p>
</div>

CSS
.camera {
  --ink: #eeece6;      /* アイコンとボタンの線の色 */
  --surface: #262626;  /* ボタンの地 */
  --bg: #111111;       /* カメラの画面の地 */
  --size: 32px;        /* アイコンの大きさ(ボタンはこの 2 倍) */
  position: relative;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 24px;
  padding: 24px;
  background: var(--bg);
  color: var(--ink);
}

.camera__view {
  position: relative;
  flex: 1 1 280px;
  max-width: 640px;
  aspect-ratio: 4 / 3;
  border-radius: 4px;
  overflow: hidden;
  background: linear-gradient(#d9ba92, #d99590);
}

.camera__view video {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* 光る幕。script.js が不透明度を上げて、すぐに消す */
.camera__flash {
  position: absolute;
  inset: 0;
  background: #ffffff;
  opacity: 0;
  pointer-events: none;
}

.camera__shutter {
  display: grid;
  place-items: center;
  flex: none;
  width: calc(var(--size) * 2);
  height: calc(var(--size) * 2);
  padding: 0;
  border: 1px solid var(--ink);
  border-radius: 50%;
  background: var(--surface);
  color: var(--ink);
  cursor: pointer;
}

.camera__shutter:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 3px;
}

.camera__icon {
  width: var(--size);
  height: var(--size);
  overflow: visible;
}

/* 外の輪。画面の上で 2px になる太さ(24 グリッドで 48 ÷ アイコンの大きさ) */
.camera__ring {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.5;
}

.camera__blades {
  fill: currentColor;
}

.camera__status {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

JavaScript
import { gsap } from "gsap";

const HOLE = 3.6; // 開いた穴の大きさ(六角形の外接円の半径。24 グリッド)
const REACH = 8.2; // 隙間を延ばす半径(羽根の面の縁より外まで)
const FLASH = 0.6; // 光ったときの白の濃さ
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const f = (v) => Math.round(v * 1000) / 1000;

// 抜く形: 六角形の穴と、穴の各辺を延ばした 6 本の隙間。a は穴の大きさ、deg は模様の角度。
// 穴が 0 になると、隙間は中心から出る 6 本の線になる(羽根が閉じた形)
function irisCut(a, deg) {
  const verts = [];
  const slits = [];
  for (let k = 0; k < 6; k++) {
    const p = ((deg + k * 60) * Math.PI) / 180;
    const q = p + (2 * Math.PI) / 3; // 辺の向き
    const x = 12 + a * Math.cos(p);
    const y = 12 + a * Math.sin(p);
    const s = a / 2 + Math.sqrt(REACH * REACH - (3 * a * a) / 4);
    verts.push(`${f(x)} ${f(y)}`);
    slits.push(`M${f(x)} ${f(y)}L${f(x + s * Math.cos(q))} ${f(y + s * Math.sin(q))}`);
  }
  return `M${verts.join("L")}Z${slits.join("")}`;
}

document.querySelectorAll(".camera").forEach((camera, i) => {
  const button = camera.querySelector(".camera__shutter");
  const mask = camera.querySelector("mask");
  const cut = camera.querySelector(".camera__cut");
  const body = camera.querySelector(".camera__body");
  const flash = camera.querySelector(".camera__flash");
  const status = camera.querySelector(".camera__status");
  // 同じページに何個あっても形を取り違えないように、mask の id を付け直す
  mask.id = `camera-iris-${i + 1}`;
  camera.querySelector(".camera__blades").setAttribute("mask", `url(#${mask.id})`);

  // 見た目はこの 3 つの値から描く(閉じる量・模様の角度・押したときの縮み)
  const state = { c: 0, rot: 0, press: 1 };
  const draw = () => {
    cut.setAttribute("d", irisCut(HOLE * (1 - state.c), state.rot));
    body.setAttribute("transform", `translate(12 12) scale(${f(state.press)}) translate(-12 -12)`);
  };
  draw();

  let shots = 0;
  let anim = null;
  button.addEventListener("click", () => {
    shots += 1;
    // ここで実際の撮影をする(video の映像を canvas に写すなど)
    status.textContent = `${shots}枚目を撮りました`;
    anim?.kill(); // 続けて押しても、今の値から次の 1 回へつなぐ
    anim = gsap.timeline({ onUpdate: draw });
    if (reduceMotion) {
      // 動きを減らす設定では羽根を動かさず、弱い光だけで知らせる
      anim.fromTo(flash, { opacity: FLASH / 2 }, { opacity: 0, duration: 0.3, ease: "power1.out" });
      return;
    }
    // 角度は「撮った回数 × 60°」で決める("+=60" だと、回っている途中に押したとき半端な角度で止まる)
    anim
      .to(state, { press: 0.92, duration: 0.07, ease: "power2.out" }, 0)
      .to(state, { press: 1, duration: 0.35, ease: "back.out(2)" }, 0.07)
      // 30° 回りながら閉じ、少し止まって、さらに 30° 回りながら開く(六角形なので 60° で元の模様に戻る)
      .to(state, { c: 1, rot: shots * 60 - 30, duration: 0.16, ease: "power2.in" }, 0.02)
      .to(state, { c: 0, rot: shots * 60, duration: 0.45, ease: "power2.out" }, 0.24)
      // 閉じきった瞬間に光らせて、すぐに消す
      .to(flash, { opacity: FLASH, duration: 0.04, ease: "none" }, 0.14)
      .to(flash, { opacity: 0, duration: 0.55, ease: "power2.out" }, 0.18);
  });
});

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

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