デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
見出しを1文字ずつ打ち出す、画面に入ったときに一度だけ動く演出です。文字数を0から文の長さまで数えるだけの値をトゥイーンし、その数だけ文を切り出して表示しています。完成した文は透明のまま置いて幅を確保し、打ち出す行をその上に重ねています。
1文字あたり0.16秒の速さで打ちます。和文は1字が欧文の倍ほど広いので、欧文と同じ間隔で打つとカーソルが走るように見えます。カーソルの点滅はCSSのステップ式のアニメーションで表しているので、打っている間だけクラスを外して点灯させたままにしています。
和文のかなと漢字は全角でどれも同じ幅なので、等幅の書体でなくても、カーソルが1打ごとに同じ幅だけ進みます。ただし書体によってはかなの組み合わせで字を詰めるので、font-kerningで詰めを切っています。詰めが残ると、カーソルの進む間隔が字ごとにばらつき、リズムが読み取りにくくなります。読み上げには完成した文をそのまま渡し、打ち出す行とカーソルは読み上げから外しています。
つまみは打つ速さと書体です。欧文の見出しを打つなら、等幅の書体にするとリズムがそろいます。ヒーローのキャッチコピーや、開発者向けサイトのコード風の見出しに向いています。
コード
<section class="typewriter">
<h2 class="typewriter__heading" aria-label="長く愛されるものを、つくろう。">
<span class="typewriter__ghost" aria-hidden="true">長く愛されるものを、つくろう。</span>
<span class="typewriter__line" aria-hidden="true">
<span class="typewriter__typed"></span><span class="typewriter__cursor"></span>
</span>
</h2>
</section>
.typewriter {
display: grid;
place-items: center;
min-height: 60vh;
padding: 96px 6vw;
background: #111111;
color: #eeece6;
}
.typewriter__heading {
position: relative;
margin: 0;
max-width: 18em;
font-size: clamp(20px, 5vw, 48px);
font-weight: 800;
line-height: 1.4;
letter-spacing: -0.02em;
/* 和文の全角の字はどれも同じ幅。字詰めだけ切ると、1 打ごとにカーソルが同じ幅だけ進む */
font-kerning: none;
}
/* 完成した文を透明のまま置き、折り返す位置ごと見出しの幅を確保する */
.typewriter__ghost {
visibility: hidden;
}
/* 打ち出す行は、幅を確保した見出しに同じ幅で重ね、折り返す位置をそろえる */
.typewriter__line {
position: absolute;
inset: 0;
}
.typewriter__cursor {
display: inline-block;
width: 0.09em;
height: 1.08em;
margin-left: 0.06em;
/* 幅を確保した見出しにはカーソルの分が無いので、右の余白で打ち消して、最後の字の後ろで次の行へ落ちないようにする */
margin-right: -0.15em;
vertical-align: -0.16em;
background: #eeece6;
animation: typewriter-blink 1s steps(1) infinite;
}
/* 打っている・消しているあいだは点灯したまま。止まっている間だけ点滅する */
.typewriter.is-typing .typewriter__cursor {
animation: none;
}
@keyframes typewriter-blink {
50% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.typewriter__cursor { animation: none; }
}
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 字形が確定してから打ち始める(書体が差し替わると、確保した幅と折り返す位置が変わる)
document.fonts.ready.then(() => {
document.querySelectorAll(".typewriter").forEach((root) => {
const typed = root.querySelector(".typewriter__typed");
const text = root.querySelector(".typewriter__ghost").textContent;
if (reduceMotion) {
typed.textContent = text;
return;
}
const type = () => {
root.classList.add("is-typing");
const state = { n: 0 };
gsap.to(state, {
n: text.length,
duration: text.length * 0.16, // 1 字 0.16 秒。和文は 1 字が欧文の倍ほど広いので、欧文より間をあける
ease: "none",
onUpdate: () => (typed.textContent = text.slice(0, Math.round(state.n))),
onComplete: () => root.classList.remove("is-typing"),
});
};
// 画面に入ったら 1 回だけ打つ
const io = new IntersectionObserver(
(entries, self) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
self.unobserve(entry.target);
type();
}
},
{ rootMargin: "0px 0px -15%" }
);
io.observe(root);
});
});