css(Scss)で一文字ずつ表示、かつ背景色の帯も徐々に伸長するエフェクトを付ける

こういうアニメーションやエフェクトって文字で起こすとさっぱり伝わらないですよね……動きを言語に落とし込むことの難しさを改めて感じます。

とりあえず完成形のデモを以下に示します。

また、デモのリポジトリは以下になります。

呼びづらいので、以下このエフェクトを仮に「タイピングスライドイン」と呼ぶことにします(もし一般的な名称がありましたらこっそり教えていただけると助かります)。

タイピングスライドインのエフェクトは、デモをご覧の通り大きく分けて2つのアニメーションで構成されています。

  • タイピング: 一文字ずつ表示する
  • スライドイン: 背景色の帯が伸長する

今回は、その両方ともcss(正確にはScss)のみJavaScriptは使わずに実装しました。

サンプルコードは以下。なお、リポジトリにある実際のコードより若干簡素化し、パーツに分解してあります。

ejs

<%
commonVar['catch1'] = "\"オブジェクト\"を制する者は、";
commonVar['catch2'] = "JavaScriptを制す!";
%>
    <section class="container-fluid eyecatch">
        <div class="container eyecatchContainer">
<%
    const catchStrArray1 = commonVar['catch1'].split('');
    let spanTags1 = '';
    for(let i = 0;i < catchStrArray1.length; i++) {
        spanTags1 += '<span>' + catchStrArray1[i] + '</span>';
    }
%>
            <p class="py-1 pl-3 mb-2 catchCopy catchCopy1"><%- spanTags1 %></p>
<%
    const catchStrArray2 = commonVar['catch2'].split('');
    let spanTags2 = '';
    for(let i = 0;i < catchStrArray2.length; i++) {
        spanTags2 += '<span>' + catchStrArray2[i] + '</span>';
    }
%>
            <p class="py-1 pl-3 mb-0 catchCopy catchCopy2"><%- spanTags2 %></p>
        </div>
    </section>

今回はspanタグを逐一付与するのが面倒だったのでejsのfor文で回してしまいましたが、HTMLでベタ書きでも同じことはできます。

<section class="container-fluid eyecatch">
    <div class="container eyecatchContainer">
        <p class="py-1 pl-3 mb-2 catchCopy catchCopy1"><span>"</span><span>オ</span><span>ブ</span><span>ジ</span><span>ェ</span><span>ク</span><span>ト</span><span>"</span><span>を</span><span>制</span><span>す</span><span>る</span><span>者</span><span>は</span><span>、</span></p>
        <p class="py-1 pl-3 mb-0 catchCopy catchCopy2"><span>J</span><span>a</span><span>v</span><span>a</span><span>S</span><span>c</span><span>r</span><span>i</span><span>p</span><span>t</span><span>を</span><span>制</span><span>す</span><span>!</span></p>
    </div>
</section>

肝はコピー文に1文字ずつspanタグが付与されていることです。

css(Scss)

デザイン部分

.eyecatch {
    height: calc(100vh - #{$navbar-height}); /* ヘッダメニューの高さを引く */
    overflow: hidden;
    background: {
        image: url("../img/eyecatch.jpg");
        repeat: no-repeat;
        position: center top;
        size: cover;
    }
    display: flex;
    justify-content: flex-start;
    align-items: center;
    &Container {
        display: flex;
        justify-content: flex-start;
        align-items: flex-start;
        flex-direction: column;
    }
    .catchCopy {
        color: #333;
        letter-spacing: 0;
        font-size: 2.2rem;
    }
}

こちらはデザインを整えるためなので、アニメーションには直接関係しません。

アニメーション部分(今回の肝)

/* 変数 */
$catch1: "\"オブジェクト\"を制する者は、";
$catch2: "JavaScriptを制す!";

/* キーフレームアニメーションの設定 */
@keyframes expand {
    0%        { width: 0%; }
    100%    { width: 100%; }
}
@keyframes fade {
    0%        { color: transparent; }
    100%    { color: #333; }
}

.eyecatch {
    .catchCopy {
        display: inline-block;
        position: relative;
        z-index: 1;
        &:before {
            position: absolute;
            top: 0;
            left: 0;
            right: 0;
            bottom: 0;
            content: '';
            z-index: -1;
            background-color: rgba(61, 250, 122, 0.8);
            width: 0;
        }
        span {
            color: transparent;
        }
        $letterCnt1: str-length($catch1);
        $letterCnt2 : str-length($catch2);
        /* 1行目 */
        &1 {
            &:before {
                animation: expand #{$letterCnt1*0.1s} ease-out 1 normal;
                animation-fill-mode: forwards;
            }
            span {
                animation: fade #{$letterCnt1*0.1s} ease-out 1 normal;
                animation-fill-mode: forwards;
                @for $i from 1 through $letterCnt1 {
                    &:nth-child(#{$i}) {
                        animation-delay: (#{$i*0.1s});
                    }
                }
            }
        }
        /* 2行目 */
        &2 {
            &:before {
                animation: expand #{$letterCnt2*0.1s} ease-out #{$letterCnt1*0.1s} 1 normal;
                animation-fill-mode: forwards;
            }
            span {
                animation: fade #{$letterCnt2*0.1s} ease-out 1 normal;
                animation-fill-mode: forwards;
                @for $j from 1 through $letterCnt2 {
                    &:nth-child(#{$j}) {
                        animation-delay: (#{$letterCnt1*0.1s+$j*0.1s});
                    }
                }
            }
        }
    }
}

こちらが肝のアニメーションに関与する部分。上から順番に見ていきます。

$catch1: "\"オブジェクト\"を制する者は、";
$catch2: "JavaScriptを制す!";

変数の中身はejsで使うものと同じです。

本筋とは関係ない部分ではありますが、この変数は実際は前記事正規表現でカラーコードの場合のみダブルクーテーションを外すJavaScriptを書くで取り上げたようにYAMLファイルで共通にパラメータを引っ張ってきています。

@keyframes expand {
    0%        { width: 0%; }
    100%    { width: 100%; }
}
@keyframes fade {
    0%        { color: transparent; }
    100%    { color: #333; }
}

次にキーフレームアニメーション。expandがスライドインで、fadeがタイピングの部分です。

.catchCopy {
    display: inline-block;
    position: relative;
    z-index: 1;
    &:before {
        position: absolute;
        top: 0;
        left: 0;
        right: 0;
        bottom: 0;
        content: '';
        z-index: -1;
        background-color: rgba(61, 250, 122, 0.8);
        width: 0;
    }
}

スライドインは疑似要素beforeで実装しています。これは.catchCopy(pタグ)に直にアニメーションを指定すると親要素のアイキャッチ画像の100%、つまり画面幅まで伸びてしまうため。

疑似要素を使うので、.catchCopyposition: relative;を指定し、beforeposition: absolute;します。beforeの幅は最初はwidth: 0;で。

span {
    color: transparent;
}

文字も最初は透明にしておきます。

$letterCnt1: str-length($catch1);
$letterCnt2 : str-length($catch2);

Scssのデフォルトで文字数をカウントする関数str-lengthが用意されているので、それを使って冒頭で宣言したコピー文の文字数を取得します。

&1 {
    &:before {
        animation: expand #{$letterCnt1*0.1s} ease-out 1 normal;
        animation-fill-mode: forwards;
    }
    span {
        animation: fade #{$letterCnt1*0.1s} ease-out 1 normal;
        animation-fill-mode: forwards;
        @for $i from 1 through $letterCnt1 {
            &:nth-child(#{$i}) {
                animation-delay: (#{$i*0.1s});
            }
        }
    }
}

さて、メインのコピー文の1行目部分です。

&:beforeはスライドインのアニメーションについて、キーフレームアニメーションexpandを実際に指定しています。処理にかかる時間は#{$letterCnt1*0.1s}、つまりコピー文の文字数×0.1秒です。ease-outで1回のみ実行する指定ですね。

また、animation-fill-mode: forwards;でアニメーション終了後は終了後の状態を保持するようにしています。

spanセレクターの中がタイピングの処理です。

animation: fade #{$letterCnt1*0.1s} ease-out 1 normal;
animation-fill-mode: forwards;

この部分は背景色のスライドインと同じで、指定するアニメーションがexpandからfadeに変わっただけです。

その後の部分で、一文字ごとに0.1秒間隔で遅延するようにanimation-delayをかけています。

cssならば

span:nth-child(1) {
    animation-delay: 0.1s);
}
span:nth-child(2) {
    animation-delay: 0.2s);
}
span:nth-child(3) {
    animation-delay: 0.3s);
}
/* 略 */
span:nth-child(15) {
    animation-delay: 1.5s);
}

と書く形になりますが、15個("オブジェクト"を制する者は、の文字数)もベタで書くのは面倒なので、Scssの@for文を使って繰り返し記述をしています。

@for $i from 1 through $letterCnt1 {

条件は1文字目からコピー文の文字数までですね。

&:nth-child(#{$i}) {
    animation-delay: (#{$i*0.1s});
}

:nth-childの丸括弧の中が$iなので、ループのn文字目ということになります。

そして、animation-delayプロパティの値が$i*0.1sなので、何文字目かに応じて0.1s, 0.2s, 0.3s, … と増加していく、これによって一文字ずつ異なる秒数でディレイをかける、タイピングのアニメーションとなります。

2行目に対するアニメーションの指定は1行目とほぼ同じです。expandアニメーションの場合は以下のようになっています。

animation: expand #{$letterCnt2*0.1s} ease-out #{$letterCnt1*0.1s} 1 normal;

animationプロパティの一か所#{$letterCnt1*0.1s}だけが異なります。このanimation-delayの指定で、1行目のコピー文の処理が終わったタイミングから2行目のアニメーションを実行しています。

このディレイは文字の処理でも同じ理屈で、

animation-delay: (#{$letterCnt1*0.1s+$j*0.1s});

$letterCnt1*0.1sで1行目のコピー文のアニメーション分待機し、それに1行目と同じように1文字ずつディレイをかける$j*0.1sを前者に加算することで実装しています。

これでめでたく、デモの通りタイピングスライドインの動きになる、というわけです。

参考

cssで1文字ずつエフェクトを付ける

Scssの@for構文、animation-delayに付けたい場合について

animationのパラメータ周りについて

この記事を書いた人

アバター

アルム=バンド

フルスタックエンジニアっぽい何か。LAMPやNodeからWP、gulpを使ってejs,Scss,JSのコーディングまで一通り。たまにRasPiで遊んだり、趣味で開発したり。