デモ
デモを読み込めませんでした。
仕様
- 用途
- アイコン
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
封筒は、胴・ふたの表・ふたの裏・手紙の 4 つの形を重ねて描きます。リンクにカーソルを重ねると、ふたが上の辺を軸に手前へ返って開き、手紙が半分ほど上がってのぞきます。離れると同じ動きを逆にたどり、手紙が沈んでからふたが閉じます。
ふたは回さずに、三角の頂点の位置を計算して形(d)を書き換えます。上の辺を軸に返すと、頂点は軸から深さ 7(24 のグリッドで)の cos の分だけ上下し、sin の分だけ手前へ出ます。手前へ出た分だけ遠近をかけて中心から離すと、真横を向く途中で頂点が軸より少し上へふくらみ、平らな図形でも手前へ返ったように見えます。手前を向くほどふたを明るく塗って、陰影も付けます。
90° を越えると、ふたの裏は手紙の奥に回ります。1 つの形のまま動かすと、開いたふたが手紙の前に重なって手紙を隠してしまいます。手紙より手前に置いた表と、奥に置いた裏の 2 つの形を用意し、90° を境に描くほうを切り替えます。明るさは表と裏で同じ式から出すので、入れ替わる所で色が飛びません。
ふたは 0.4 秒の power2.inOut で開き、手紙はふたが 90° を越えるころ(0.22 秒後)から 0.45 秒の power3.out で 8 だけ上げます。離れるときは同じ動きを逆にたどるので、back.out のように行き過ぎる ease にすると、手紙が沈む前にいったん跳ね上がって見えます。閉じるほうは 2 倍の速さにしておくと、リンクの上を横切っただけのときにすぐ元に戻ります。
押すとページへ移るので <a> にし、アイコンは読み上げから外して、名前は「お問い合わせ」の文言に持たせます。キーボードで焦点が入ったときも開きます。動きを減らす設定では、ほぼ一瞬で切り替えます。線の太さは 24 のグリッドで 1.4(手紙の行は 1.05)にして、ほかの線のアイコンと並べても重く見えない細さにしています。このデモは 24px のアイコンを 9 倍に拡大して見せています。お問い合わせやニュースレターの登録への導線、フッターの連絡先のリンクに向いています。
コード
<!-- 押すとページへ移るので a。アイコンは飾りなので読み上げから外し、名前は文言が持つ -->
<a class="mail-link" href="/contact/">
<svg class="mail-link__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<!-- 奥から: ふたの裏 → 手紙 → 胴 → ふたの表。ふたの形は script.js が描く -->
<path class="mail-link__back" />
<g class="mail-link__letter">
<rect x="7" y="9.5" width="10" height="9.5" rx="0.5" />
<path d="M9.5 11.9h5M9.5 13.7h3" />
</g>
<path class="mail-link__body" d="M4.5 9h15a1.5 1.5 0 0 1 1.5 1.5v8a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 18.5v-8A1.5 1.5 0 0 1 4.5 9Z" />
<path class="mail-link__front" d="M3 9L12 16L21 9Z" />
</svg>
<span>お問い合わせ</span>
</a>
.mail-link {
--ink: #eeece6; /* 線と文字の色 */
--ground: #111111; /* 封筒の塗り(地の色) */
--paper: #262626; /* 手紙の塗り。ふたが手前を向いたときの明るさにもなる */
--size: 24px; /* アイコンの大きさ */
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--ink);
font-size: 15px;
font-weight: 600;
line-height: 1;
letter-spacing: 0.04em;
text-decoration: none;
}
/* 24 グリッドで線幅 1.4(24px で描けば 1.4px)。線端と角は丸。面は地の色で塗り、奥の線を隠す */
.mail-link__icon {
flex: none;
width: var(--size);
height: var(--size);
overflow: visible;
fill: var(--ground);
stroke: currentColor;
stroke-width: 1.4;
stroke-linecap: round;
stroke-linejoin: round;
}
.mail-link__letter rect {
fill: var(--paper);
}
.mail-link__letter path {
fill: none;
stroke-width: 1.05;
}
.mail-link:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 4px;
}
import { gsap } from "gsap";
const HINGE = 9; // ふたの軸(上の辺)の y(24 のグリッドで)
const DEPTH = 7; // ふたの深さ
const EYE = 36; // 透視の視点までの距離。小さいほど、返る途中で大きくふくらむ
const CENTER = 14.5; // 透視の中心の y(封筒の中央)
const RISE = 8; // 手紙が上がる量
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll(".mail-link").forEach((link) => {
const front = link.querySelector(".mail-link__front");
const back = link.querySelector(".mail-link__back");
const letter = link.querySelector(".mail-link__letter");
const styles = getComputedStyle(link);
// 色は CSS が正。GSAP は var(--name) のままでは混ぜられないので、値にして渡す
const shade = gsap.utils.interpolate(styles.getPropertyValue("--ground").trim(), styles.getPropertyValue("--paper").trim());
const state = { open: 0, lift: 0 };
const draw = () => {
// ふたは回さずに、上の辺を軸に返したときの頂点の位置を透視で求めて、三角を描き直す
const angle = state.open * Math.PI;
const z = DEPTH * Math.sin(angle);
const tip = CENTER + (HINGE + DEPTH * Math.cos(angle) - CENTER) * (EYE / (EYE - z));
// 90° までは表を手紙の手前に、越えたら裏を手紙の奥に描く(1 つの形のままだと、開いたふたが手紙を隠す)
const [face, hidden] = state.open <= 0.5 ? [front, back] : [back, front];
face.setAttribute("d", `M3 ${HINGE}L12 ${tip.toFixed(3)}L21 ${HINGE}Z`);
hidden.setAttribute("d", "");
face.style.fill = shade(Math.sin(angle)); // 手前を向くほど明るく塗る
letter.setAttribute("transform", `translate(0 ${(-RISE * state.lift).toFixed(3)})`);
};
draw();
// 手紙は行き過ぎさせない(閉じるときは逆にたどるので、行き過ぎる ease だと沈む前に跳ね上がる)
const hover = gsap
.timeline({ paused: true, onUpdate: draw })
.to(state, { open: 1, duration: 0.4, ease: "power2.inOut" }, 0)
.to(state, { lift: 1, duration: 0.45, ease: "power3.out" }, 0.22);
// 閉じるのは 2 倍の速さ。動きを減らす設定では、ほぼ一瞬で切り替える
const open = () => hover.timeScale(reduceMotion ? 20 : 1).play();
const close = () => hover.timeScale(reduceMotion ? 20 : 2).reverse();
link.addEventListener("mouseenter", open);
link.addEventListener("mouseleave", close);
// キーボードで焦点が入ったときも開く
link.addEventListener("focus", open);
link.addEventListener("blur", close);
});