デモ
デモを読み込めませんでした。
仕様
- 用途
- アイコン
- 動作
- 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 倍に拡大して見せています。カメラで撮る画面や、プロフィール写真・書類を撮る画面のシャッターに向いています。
コード
<!-- 映像のプレビューと、撮るボタン。光る幕はプレビューの上に重ねる -->
<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>
.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;
}
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);
});
});