CSSのテキストアニメーションサンプル

こちらの記事は「[Relic Advent Calendar 2022](https://qiita.com/advent-calendar/2022/relic)」の17日目の記事です！

## とりあえずサンプルはこちら
<p class="codepen" data-height="300" data-default-tab="html,result" data-slug-hash="KKBPMOY" data-user="_sleeepin" style="height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/_sleeepin/pen/KKBPMOY">
  CSS text animation</a> by altn (<a href="https://codepen.io/_sleeepin">@_sleeepin</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>


はじめまして、動きのあるwebサイトが好きな者です。
この記事では、見出しやメインビジュアルで使えるCSSのテキストアニメーションの紹介をしていきたいと思います。
「CSSでここまで動かせるんだ！」「こういうプロパティの使い方があるんだ！」とCSSアニメーションの面白さ・奥深さを少しでも感じていただけたら嬉しいです。

<dl>
  <dt>想定読者：</dt>
  <dd>・CSSアニメーション初心者</dd>
  <dd>・アニメーションの引き出しを増やしたいと思っている方</dd>
  <dd>・ギャラリーサイトに載っているようなカッコいいサイトを作ってみたいと思っている方</dd>
</dl>

### この記事で書かないこと
- htmlの構造について(サンプルのcodepenをみてね)
- 各CSSプロパティの説明(MDNを読んでね)
- javascriptの説明(最低限だけ使ってます)

### 前提条件
- 一文字ずつ文字が出現するアニメーションは、javascriptで一文字ずつ分割して `span` でくくっています。意外と力技です。
- 各ブロック内のテキストが画面内に入ったら `is-active` というアニメーション発火用のクラスを付与しています。
- windows/chromeでのみ表示確認を行っていますので、業務で使用する際は実機チェックを十分に行ってください。
- あくまでアニメーションを紹介するためのものなので、アクセシビリティ対応はこちらのサンプルでは行っていません。


:::note warn
アクセシビリティについて
- テキストを一文字ずつ `span` でくくるような実装はアクセシビリティ的にはよろしくないようです。具体的な対応方法については[こちらの記事](https://zenn.dev/kobito/articles/edb64bcfdc1d74)がわかりやすかったので紹介させていただきます。
- 今回紹介するアニメーションには文字が点滅するアニメーションも含まれています。ユーザーによっては発作を引き起こす演出になるため、利用には十分気を付けましょう。
- [prefers-reduced-motion](https://developer.mozilla.org/ja/docs/Web/CSS/@media/prefers-reduced-motion)という、余分なアニメーションを無効にしたいユーザー向けに用意されているメディアクエリも合わせて使ってみるといいかもしれません。
:::

## ①下から一文字ずつでてくるやつ
![css_anim_1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3070364/fd654039-068d-1209-365c-a5518c4bf1c9.gif)

<details><summary>scss　※長いので折り畳んでます</summary>

```scss
.text-split {
  overflow: hidden;
  span {
    // 一文字ずつspanで囲まれた要素
    transform: translateY(100%);
    .is-active & {
      transform: translateY(0);
      transition: transform cubic-bezier(0.7, 0.2, 0.1, 1) 0.8s;
      @for $i from 1 through 13 {
        &:nth-child(#{$i}) {
          // 若干時差をつけながら出現させる
          transition-delay: #{($i - 1) * 0.04s};
        }
      }
    }
  }
}
```
</details>


`.text-split` は Relic / CO-INNOVATION / COMPANY. それぞれのテキストを囲む要素につけているクラスです。(以降も同じ※一部例外あり)
`overflow: hidden` をかけた親要素の中で、`span` が `transform: translateY` で上下しているというシンプルな仕組みです。
下からではなく、上から降ってくる感じにしてもいい感じになります。

## ②若干角度をつけながら下から一文字ずつ出てくる
![css_anim_2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3070364/a1791015-ecae-fe57-cf52-3544e9303d17.gif)

<details><summary>scss</summary>

```scss
.text-split {
  overflow: hidden;
  span {
    // 一文字ずつspanで囲まれた要素
    transform: translate(-80px, 70%) rotate3d(1, 0.3, 0, 90deg);
    transform-origin: 50% 100%;
    .is-active & {
      transform: translate(0, 0) rotate3d(0, 0, 0, 0);
      transition: transform 1.4s cubic-bezier(0.08, 0.8, 0.315, 1);
      @for $i from 1 through 13 {
        &:nth-child(#{$i}) {
          transition-delay: #{($i - 1) * 0.04s};
        }
      }
    }
  }
}
```
</details>

①とほぼ同じですが、若干斜めから入ってくるような形にするために `rotate3d` の記述を追加したり、`translateX` の値を調整しています。
また、`transform-origin` で変形させる要素の基準点をbottomに変更してみました。
三角やひし形など、斜めのモチーフを使ったサイトに合いそうだなと思います。
このような感じで transform は複数の値を設定できるため、少し値を追加してみるだけで雰囲気を変えることが出来ます。

## ③スライドイン
![css_anim_3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3070364/e09ea21c-9977-580b-246b-8359f8ce029d.gif)

```html
<div class="block block--3">
   <div class="title">
     <h2 class="text-item"><span>Relic</span></h2>
     <div class="text-item"><span>CO-INNOVATION</span></div>
     <div class="text-item"><span>COMPANY.</span></div>
   </div>
</div>
```

こちらは他のサンプルとは若干htmlの構造が異なります。`overflow:hidden` をかけた `.text-item` の中にさらにテキストを囲む `span` を追加しています。

<details><summary>scss</summary>

```scss
.text-item {
  width: fit-content;
  overflow: hidden;
  transform: translateX(-100%);
  > span {
    display: block;
    // 親要素と同じ分だけ逆方向に移動させる
    transform: translateX(100%);
  }
  .is-active & {
    // 全く同じduration, delayを両方に設定する
    transform: translateX(0);
    transition: transform cubic-bezier(0.74, 0, 0.24, 0.99) 1.1s;
    > span {
       transform: translateX(0);
       transition: transform cubic-bezier(0.74, 0, 0.24, 0.99) 1.1s;
    }
    @for $i from 1 through 3 {
      &:nth-child(#{$i}) {
        transition-delay: #{($i - 1) * 0.13s};
        > span {
          transition-delay: #{($i - 1) * 0.13s};
        }
      }
    }
  }
}
```
</details>


文章だとわかりづらいので、図でスライドインの原理を説明します。
テキストを囲む `.text-item` に `transform: translateX(-100%)` をかけ要素分だけ移動させます。
![text_anim_slide_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3070364/dea12080-2bc9-6e34-0a5e-d73f29272712.png)
　　　　　　　　　　　　　　　　　↓
![text_anim_slide_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3070364/2798c56b-be94-f3fc-2adf-13eff29185a9.png)
そして中の `span` 要素を、親要素と同じ分だけ `transform: translateX(100%)` で逆方向に移動させ、元の位置に戻します。
![text_anim_slide_3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3070364/f82f14bd-a9aa-d6ea-c0dd-7277cff75847.png)
`.text-item` に `overflow: hidden` をかけることで、`.text-item` からはみ出た `span` は見えなくなります。
![text_anim_slide_4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3070364/e28cc836-ef1e-48f9-f433-8de26f8638ee.png)
最後に全く同じアニメーションで元の位置に戻すと、スライドインしたようなアニメーションになります。
↓途中経過の様子
![text_anim_slide_5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3070364/83a0aed8-70bf-33fa-37fe-89e25642a2eb.png)
`clip-path` を使えば `span` タグなしでもっとシンプルに同じアニメーションを実現できますが、`transform` のほうが動きが軽いので今回はそうしています。

## ④横からフェードイン＆グラデーション
![css_anim_4.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3070364/1ca81c1c-eec6-27bc-c394-26c21d6df5d6.gif)
<details><summary>scss</summary>

```scss
.text-split {
  span {
    // 文字色を青にする
    color: $blue;
    // 30px右からフェードイン
    opacity: 0;
    transform: translateX(30px);
    .is-active & {
      color: #fff;
      opacity: 1;
      transform: translateX(0);
      transition: opacity 1.2s cubic-bezier(0.16, 1, 0.3, 1),
        transform 1.2s cubic-bezier(0.16, 1, 0.3, 1),
        color 2s cubic-bezier(0.19, 1, 0.22, 1) .2s; // フェードインした後に文字色だけ遅れて変化させたいため、delayをつける
    }
  }
  .is-active & {
    @for $i from 1 through 3 {
      &:nth-child(#{$i}) {
        span {
         @for $j from 1 through 14 {
          &:nth-child(#{$j}) {
            // 一行ずつ時差をつけるための変数
            $delay1: ($i - 1) * 0.05s;
            // 上からopacity、transform、colorのdelay
            transition-delay:
                #{($j - 1) * 0.04s + $delay1},
                #{($j - 1) * 0.04s + $delay1},
                #{($j - 1) * 0.05s + .2s + $delay1};
            }
          } 
        }
      }
    }
  }
}
```
</details>

こちらは一文字ずつ右からフェードインさせ、少し遅れて文字色を青→白に変化させるアニメーションです。
あえて `color` が変化するタイミングを遅くしたり `transition-duration` を長めにすることで、余韻を感じられるようにしています。
最初の文字色をゴールドとかにしたら高級感が出そう。

## ⑤clip-path
![css_anim_5.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3070364/efdfd998-379c-a787-e7ff-88d605fa33b5.gif)

```html
<div class="block block--5">
  <div class="title">
    <div class="text-wrap" data-text="Relic">
      <h2 class="text-item">Relic</h2>
    </div>
    <div class="text-wrap" data-text="CO-INNOVATION">
      <p class="text-item">CO-INNOVATION</p>
    </div>
    <div class="text-wrap" data-text="COMPANY.">
      <p class="text-item">COMPANY.</p>
    </div>
  </div>
</div>
```

それぞれのテキストを囲む `.text-wrap`の `data-text` 属性にテキストを指定します。(疑似要素作成のため)

<details><summary>scss</summary>

```scss
.text-wrap {
  position: relative;
  width: fit-content;
  z-index: 1;
  &::before,
  &::after {
    // 同じテキストの疑似要素を作成し、元のテキストにぴったりと重ねる
    position: absolute;
    content: attr(data-text);
    top: 0;
    left: 0;
    width: 100%;
    z-index: -1;
    clip-path: polygon(0 0, 0 0, -5% 100%, 0% 100%);
  }
  &::before {
    color: $yellow;
  }
  &::after {
    color: #000;
  }
  .is-active & {
    &::before,
    &::after {
      // アニメーション終了後非表示にする必要があるため、opacityを0にする
      opacity: 0;
      clip-path: polygon(0 0, 105% 0, 100% 100%, 0% 100%);
      transition: clip-path 1s cubic-bezier(0.42, 0.06, 0.1, 1), opacity 0s;
    }
    @for $i from 1 through 3 {
      &:nth-child(#{$i}) {
        &::before {
          // clip-pathのdelay、opacityのdelay
          transition-delay: #{($i - 1) * .12s}, #{($i - 1) * .12s + 1s};
        }
        &::after {
          // clip-pathのdelay、opacityのdelay
          transition-delay: #{($i - 1) * .12s + .1s}, #{($i - 1) * .12s + 1.1s};
        }
        .text-item {
          transition-delay: #{($i - 1) * .12s + .25s};
        }
      }
    }
  }
}

// 最終的に表示する要素
.text-item {
  color: $blue;
  clip-path: polygon(0 0, 0 0, -5% 100%, 0% 100%);
  .is-active & {
     clip-path: polygon(0 0, 105% 0, 100% 100%, 0% 100%);
     transition: clip-path 1s cubic-bezier(0.42, 0.06, 0.1, 1) 0.25s;
  }
}
```
</details>

- 疑似要素で作った2つのテキスト要素を、absoluteで元のテキストと同じ位置に重ねます。(この際、最後に表示される要素が重なり順的に一番上に来る必要があります)
- それぞれのテキストの文字色を変えます。
-  `clip-path` で斜めにスライドインするアニメーションを作成します。
- `transition-delay` でそれぞれ少し遅れてアニメーションさせます。


## ⑥clip-path & グラデーション
![css_anim_6.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3070364/91774755-83c0-9470-a45f-b0c0b207b996.gif)

<details><summary>scss</summary>

```scss
.text-split {
  span {
     color: red;
     // clip-pathパターン①
     clip-path: polygon(0 50%, 100% 50%, 100% 50%, 0 50%);
     &:nth-of-type(3n+2) {
       // clip-pathパターン②
       clip-path: polygon(0 0, 1% 0, 1% 100%, 0 0);
     }
     &:nth-of-type(3n+3) {
       // clip-pathパターン③
       clip-path: polygon(0 100%, 100% 100%, 100% 100%, 0 100%);
     }
    .is-active & {
      color: #fff;
      clip-path: polygon(100% 0, 100% 100%, 0 100%, 0 0);
      transition:
        clip-path 0.65s cubic-bezier(0.165, 0.84, 0.44, 1),
        color 1s cubic-bezier(0.19, 1, 0.22, 1);
    }
  }
  .is-active & {
    @for $i from 1 through 3 {
      span {
        @for $j from 1 through 20 {
          &:nth-child(#{$j}) {
            $delay1: ($i - 1) * 0.05s;
            transition-delay:
                #{($j - 1) * 0.03s + $delay1},
                #{($j - 1) * 0.03s + .2s + $delay1};
          }
        }
      }
    }
  }
}
```

</details>


- ランダムっぽいアニメーションにするため、3種類の `clip-path` を用意して隣接する文字と被らないように指定します。
- `clip-path` のアニメーション後、少し遅れて文字色を赤→白に変化させます。
- 「自分で未来を切り拓け。」みたいなかっこいいキャッチに似合いそうだなと思いながら作りました。

## ⑦テキストが真ん中から分割された状態で出現
![css_anim_7.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3070364/b3fd0313-93bb-3eac-0cde-2fe77519badc.gif)

```html
<div class="block block--7">
  <div class="title">
    <h2 class="text-item">
      Relic
      <span class="text-half text-half--top" data-text="Relic"></span>
      <span class="text-half text-half--btm" data-text="Relic"></span>
    </h2>
    <div class="text-item">
      CO-INNOVATION
      <span class="text-half text-half--top" data-text="CO-INNOVATION"></span>
      <span class="text-half text-half--btm" data-text="CO-INNOVATION"></span>
    </div>
    <div class="text-item">
      COMPANY.
      <span class="text-half text-half--top" data-text="COMPANY."></span>
      <span class="text-half text-half--btm" data-text="COMPANY."></span>
    </div>
  </div>
</div>
```

<details><summary>scss</summary>

```scss
.text-item {
  position: relative;
  color: transparent;
  .text-half {
    display: block;
    position: absolute;
    left: 0;
    width: 100%;
    height: 50%; // 高さを半分にし、上下に配置
    overflow: hidden;
    &::before {
      position: absolute;
      content: attr(data-text);
      left: 0;
      white-space: no-wrap;
      color: #fff;
    }
    &--top {
      top: 0;
      &::before {
        top: 0;
        transform: translateY(100%);
      }
    }
    &--btm {
      bottom: 0;
      &::before {
        bottom: 0;
        transform: translateY(-100%);
      }
    }
  }
  .is-active & {
    .text-half {
      &::before {
        transform: translateY(0);
        transition: 1s cubic-bezier(0.25, 1, 0.5, 1) .1s;
      }
    }
  }
}
```
</details>

- `.text-item`の50%の高さの `span` 要素を二つ作成し、absoluteで上下に配置します。
- `span` に `overflow: hidden` をかけ、半分しか見えないようにします。
- `span` の疑似要素でテキストを作成し、テキストの上下半分ずつ表示
- あとは `span` の中で `transform: translateY()` でアニメーションさせると真ん中の線からにゅっと出現したかのような演出になります。

## ⑧ランダムな順番で一文字ずつフェードイン
![css_anim_8.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3070364/282f7ce9-cfd2-8094-48be-76c3009f3923.gif)

<details><summary>scss</summary>

```scss
.text-split {
  span {
    opacity: 0;
    .is-active & {
      opacity: 1;
      transition: opacity 0.4s cubic-bezier(0.12, 0, 0.39, 0);
      @for $i from 1 through 7 {
        &[data-random="#{$i}"] {
          transition-delay: #{($i - 1) * 0.03s};
        }
      }
    }
  }
}
```
</details>

- テキストを一文字ずつjsで分割する際、`data-random` 属性に 1～7 のランダムな数字を割り当てるようにします。
```javascript
text.forEach((t, index) => {
    newText += `<span data-random="${getRandomInt(1, 8)}">${t}</span>`;
});
```
- テキストをフェードインで表示させますが、 `transition-delay` のタイミングを `data-random` の値によって設定することで、出現タイミングをランダムにしています。
```scss
@for $i from 1 through 7 {
  &[data-random="#{$i}"] {
    transition-delay: #{($i - 1) * 0.03s};
  }
}
```
- ランダムにすることで、ただのフェードインとはまた違った印象になるかと思います。

## ⑨デジタルチックな点滅アニメーション
![css_anim_9.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3070364/2b636775-9d59-d3c2-a6e7-70cbf456f2fc.gif)

<details><summary>scss</summary>

```scss
.text-split {
  span {
    opacity: 0;
    .is-active & {
      animation: flash 0.1s 2 cubic-bezier(0.47, 0, 0.745, 0.715) forwards normal 0.2s;
      @for $i from 1 through 14 {
        &:nth-child(#{$i}) {
          animation-delay: #{($i - 1) * 0.025s + .1s};
        }
      }
    }
  }
}

@keyframes flash {
  0% {
    opacity: 1;
  }
  50% {
    opacity: 0.4;
  }
  100% {
    opacity: 1;
  }
}
```
</details>

- flashという点滅用のkeyframeアニメーションを作成し、2回繰り返し再生しています。
- SFとかデジタルっぽいサイトに合いそう

## ⑩点滅アニメーション2
![css_anim_10.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3070364/656922ae-1a66-831c-fbf9-bb47b87929dc.gif)

<details><summary>scss</summary>

```scss
.text-split {
  span {
     opacity: 0;
     text-stroke: 1 #fff;
    .is-active & {
      animation: text-blink backwards 0.34s steps(1);
      opacity: 1;
      // nth-childでdelayを設定しつつ、data-randomの値によっては順番が多少前後する
      @for $i from 1 through 14 {
        @for $j from 1 through 7 {
          &:nth-child(#{$i})[data-random='#{$j}'] {
            animation-delay: #{($i - 1) * 0.02s + ($j * 0.02s)};
          }
        }
      }
    }
  }
}

@keyframes text-blink {
  0% {
    -webkit-text-stroke-width: 0;
    opacity: 0;
    color: #fff;
  }
  12.5% {
    -webkit-text-stroke-width: 0;
    opacity: 0.5;
    color: $yellow;
  }
  25% {
    -webkit-text-stroke-width: 0;
    opacity: 1;
    color: rgba(#000, 0.35);
  }
  37.5% {
    -webkit-text-stroke-width: 0;
    opacity: 0.5;
    color: #fff;
  }
  50% {
    -webkit-text-stroke-width: 0;
    opacity: 1;
    color: $yellow;
  }
  62.5% {
    -webkit-text-stroke-width: 0;
    opacity: 0;
    color: #fff;
  }
  75% {
    -webkit-text-stroke-width: 0;
    opacity: 1;
    color: rgba(#000, 0.35);
  }
  87.5% {
    opacity: 0.5;
    color: rgba(#000, 0);
    -webkit-text-stroke-width: 1px;
    -webkit-text-stroke-color: #000;
  }
  100% {
    -webkit-text-stroke-width: 0;
    opacity: 1;
    color: #fff;
  }
}
```
</details>

- ⑧と同じく、テキストを1文字ずつ分割する際に`data-random` 属性に 1～7 のランダムな数字を割り当てるようにします
- アニメーション自体は作成した `text-blink` keyframeアニメーションを1回再生しているだけです。
- `animation-delay` について、 nth-childでdelayを設定しつつ、 `data-random` の値によっては順番が多少前後するようにすることで、ある程度のランダム性を確保しています

```scss
@for $i from 1 through 14 {
    @for $j from 1 through 7 {
        &:nth-child(#{$i})[data-random='#{$j}'] {
            animation-delay: #{($i - 1) * 0.02s + ($j * 0.02s)};
        }
    }
}
```



紹介は以上となります！(後半になるにつれ、説明が雑になりすみません)
CSSアニメーションは手っ取り早く始められるのがいいところですし、同じアニメーションでも`duration`やイージングによって印象が大きく変わるのが面白いです。
記事のために[サンプル](https://codepen.io/_sleeepin/full/KKBPMOY)を作成しましたが、やはり動くものを作るのはわくわくしますね。

想像以上に長い記事になってしまいましたが、ここまでお読みくださった方ありがとうございました！