GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

css 区切り線 9パターン — 両端が消えて文字の幅で線が分かれる区切りの場面

css 区切り線は線一本で済みそうだが、両端がぷつりと切れ、中央の文字の幅に線の長さが合わず、セクションの境目が直線で単調になる — 01番・02番・04番が一つずつ直す。このページの九つは、線の形を値で計算して描きます。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

css 区切り線 9パターン — 線の形を計算すると端が不自然にならない

九つの場面は、計算が軽いものから順に並べました。01 は両端の透明度を決める曲線が一つだけで、02 は文字の幅を引いた残りを二つに割るだけの場面です。03 はスクロールした距離の割合で線が伸び、04 と 05 はサイン波とシード付きのノイズでセクションの境目を描きます。06 は点の大きさを釣鐘型の曲線で決め、07 は行が折り返したかどうかを判定し、08 はポインターまでの距離を使います。09 はスクロール位置ひとつで波・線・色のにじみを一緒に動かす場面です。

こんな画面で使います

ランディングページのセクションの間には 01・04・09 が合います。ログイン画面の「または」の行のように文字が挟まる場所は 02 の担当で、メニューやタグの間に入れる css 区切り線 縦 は 07 が受け持ちます。after で線を引く方法は行が変わった項目にも線を残してしまうので、07 は JS の判定でそれを避けています。記事の読み進みには 03、飾りのある紹介ページには 06 と 08 が向いています。

隣の記事とどこが違うか

メンフィス デザイン パターン ランディング 9選は点と波の模様を一枚のデザインとして載せる記事で、この記事は線や境目の形を数値で決めます。画面が切り替わる瞬間を扱うCSS 画面遷移エフェクト 9選や、スクロールで場面をつなぐスクロール ストーリーテリング セクション 9選では、区切り線を一つの要素として作り込むことはしません。

技法別のコードで見る区切り線

九つはライブラリを使わず、普通の JS と CSS だけで書いてあります。03 と 09 はどちらも scroll イベントで動きます。進み具合の求め方は 03 が getBoundingClientRect、09 が scrollTop で、IntersectionObserver はどの項目にも使っていません。下には、各場面で線の形を決める計算の区間だけをソースのまま抜き出しました。

01両端が透明に消える基本の区切り線

線の両端それぞれ30%の区間だけ、透明度が0から1へ上がります。その上がり方は直線ではなく smoothstep なので、線の始まりに境目の帯が見えません。同じグラデーションを線とその背後のにじみの両方に使い、デモの最中は FADE_PCT が6から50のあいだを往復して、動いたフレームは23個中23個でした。

両端が透明に消える基本の区切り線
新しいタブで開く

01 · 両端が透明に消える基本の区切り線 — 計測 累積面積 20.444% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FADE_PCTLINE_H_PX
  function gradientOf(pct) {
    var stops = [];
    var i;
    for (i = 0; i <= STEPS; i += 1) {
      var t = i / STEPS;
      var a = t * t * (3 - 2 * t);
      stops.push('rgba(' + RGB + ',' + a.toFixed(3) + ') ' + (pct * t).toFixed(2) + '%');
    }
    for (i = STEPS; i >= 0; i -= 1) {
      var u = i / STEPS;
      var b = u * u * (3 - 2 * u);
      stops.push('rgba(' + RGB + ',' + b.toFixed(3) + ') ' + (100 - pct * u).toFixed(2) + '%');
    }
    return 'linear-gradient(90deg,' + stops.join(',') + ')';
  }

02中央の文字の幅に合わせて両側の線が分かれる区切り線

文字の幅を getBoundingClientRect で測り、行の幅から文字と両脇の余白12pxずつを引いた残りを半分にして、二本の線の width にします。文字が替わっても線が片側に寄らず、カードの幅が180と420のあいだを往復しても毎フレーム計算し直します。動いたフレームは23/23です。

中央の文字の幅に合わせて両側の線が分かれる区切り線
新しいタブで開く

02 · 中央の文字の幅に合わせて両側の線が分かれる区切り線 — 計測 累積面積 15.208% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

LABEL_GAPLINE_H_PX
  function layout() {
    var rowW = row.getBoundingClientRect().width;
    var labelW = label.getBoundingClientRect().width;
    var side = Math.max(0, (rowW - labelW - LABEL_GAP * 2) / 2);
    left.style.width = side.toFixed(1) + 'px';
    right.style.width = side.toFixed(1) + 'px';
    pxEl.textContent = String(Math.round(side));
  }

03スクロール位置に応じて真ん中から両側へ描かれる区切り線

スクロールイベントのたびに、線が領域の底から上がってきた距離を測って80で割り、その割合だけ clip-path を両側から開きます。線は真ん中から左右へ伸び、上へ戻すと縮みます。デモではコードが scrollTop を動かしていて、動いたフレームは23/23です。

スクロール位置に応じて真ん中から両側へ描かれる区切り線
新しいタブで開く

03 · スクロール位置に応じて真ん中から両側へ描かれる区切り線 — 計測 累積面積 29.984% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

DRAW_RANGE_PXDRAW_MIN_PCT
  function update() {
    var box = scroller.getBoundingClientRect();
    var lineBox = line.getBoundingClientRect();
    var risen = box.bottom - lineBox.top;
    var p = Math.min(1, Math.max(0, risen / DRAW_RANGE_PX));
    var pct = DRAW_MIN_PCT + (100 - DRAW_MIN_PCT) * p;
    var edge = (100 - pct) / 2;
    line.style.clipPath = 'inset(0 ' + edge.toFixed(2) + '% 0 ' + edge.toFixed(2) + '% round 6px)';
    pctEl.textContent = String(Math.round(pct));
  }

04サイン波を二つ足して作る波の区切り

周期が190と83の二つのサイン波を足して境目の高さを決め、4px間隔で打った点をつないで SVG の path の d を直接組み立てます。二つの周期は割り切れないので、一つの曲線より繰り返しが見えにくく、ボタンで波の重ねが1から3まで変わります。css 区切り線 波 を作るなら、この足し算が核になります。動いたフレームは23/23です。

サイン波を二つ足して作る波の区切り
新しいタブで開く

04 · サイン波を二つ足して作る波の区切り — 計測 累積面積 11.55% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

WAVE_LAYERSWAVE_AMPWAVE_PERIOD
  function edgeY(x) {
    var y = BASE_Y;
    for (var i = 0; i < WAVE_LAYERS; i += 1) {
      y += WAVE_AMP[i] * Math.sin(2 * Math.PI * x / WAVE_PERIOD[i] + phase * WAVE_SPEED[i]);
    }
    return y;
  }
  function draw() {
    var d = 'M0 0H' + VIEW_W + 'V' + edgeY(VIEW_W).toFixed(1);
    for (var x = VIEW_W - STEP_PX; x >= 0; x -= STEP_PX) d += 'L' + x + ' ' + edgeY(x).toFixed(1);
    d += 'L0 ' + edgeY(0).toFixed(1) + 'Z';
    pathEl.setAttribute('d', d);
    layersEl.textContent = String(WAVE_LAYERS);
  }

05固定シードのノイズでぎざぎざに切った破れ紙の境目

固定シード20261008を自作の乱数関数に渡して256個の値の表を一度作り、大きなうねりと細かい揺れを混ぜて頂点の高さを決め、clip-path の polygon でシートを切ります。同じ幅なら再読み込みしても同じ形になります。破れる動きは200msから1600msで終わってから止まるため、動いたフレームは19/23です。

固定シードのノイズでぎざぎざに切った破れ紙の境目
新しいタブで開く

05 · 固定シードのノイズでぎざぎざに切った破れ紙の境目 — 計測 累積面積 0.949% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

TORN_SEEDTORN_STEPTORN_AMP
  function mulberry32(seed) {
    var a = seed >>> 0;
    return function () {
      a = (a + 0x6D2B79F5) >>> 0;
      var t = a;
      t = Math.imul(t ^ (t >>> 15), t | 1);
      t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
      return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
    };
  }
  var rand = mulberry32(TORN_SEED);
  function noiseAt(k) {
    var big = k / 4;
    var i0 = Math.floor(big);
    var f = smooth(big - i0);
    var coarse = table[i0 % 256] * (1 - f) + table[(i0 + 1) % 256] * f;
    var fine = table[(k * 7 + 3) % 256];
    return 0.65 * coarse + 0.35 * fine;
  }

06真ん中がいちばん大きい点の区切り線と一度通る光

点の直径を釣鐘型のガウス曲線で決めるので、真ん中の点が32pxで最大になり、両端の点は4pxまで小さくなります。その上を明るい点が一度だけ通って、点ごとに白を混ぜ、最大で30%まで大きくします。光が動くのは200msから1600msだけなので、動いたフレームは19/23です。

真ん中がいちばん大きい点の区切り線と一度通る光
新しいタブで開く

06 · 真ん中がいちばん大きい点の区切り線と一度通る光 — 計測 累積面積 4.761% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

DOT_COUNTDOT_MAX_PXGLINT_MS
  for (i = 0; i < DOT_COUNT; i += 1) {
    var d = document.createElement('span');
    d.className = 'dv__dot';
    host.appendChild(d);
    dots.push(d);
    var gauss = Math.exp(-Math.pow(i - center, 2) / (2 * sigma * sigma));
    sizes.push(Math.max(DOT_MIN_PX, DOT_MAX_PX * gauss));
  }
  function paint(g) {
    for (var k = 0; k < DOT_COUNT; k += 1) {
      var b = g === null ? 0 : Math.exp(-Math.pow(k - g, 2) / (2 * GLINT_WIDTH * GLINT_WIDTH));
      var px = sizes[k] * (1 + 0.3 * b);
      var c = [0, 1, 2].map(function (n) { return Math.round(BASE[n] + (LIGHT[n] - BASE[n]) * b); });
      var el = dots[k];
      el.style.width = px.toFixed(1) + 'px';
      el.style.height = px.toFixed(1) + 'px';
      el.style.background = 'rgb(' + c.join(',') + ')';
    }
  }

07行が変わっても先頭に残らない縦の区切り線

各項目の前に縦線を ::before で付けておき、JS が offsetTop を一つ前の項目と比べて、値が変わった項目に is-first を付けて線を隠します。CSS だけでは行の先頭の項目を見分けられないので、幅が変わるたびに判定し直します。カードの幅が150と336のあいだを往復して、動いたフレームは23/23です。

行が変わっても先頭に残らない縦の区切り線
新しいタブで開く

07 · 行が変わっても先頭に残らない縦の区切り線 — 計測 累積面積 29.527% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SEP_GAPSEP_H_PX
  function mark() {
    var rows = 0;
    var lastTop = null;
    items.forEach(function (li) {
      var top = li.offsetTop;
      var first = lastTop === null || top !== lastTop;
      if (first) rows += 1;
      li.classList.toggle('is-first', first);
      lastTop = top;
    });
    rowsEl.textContent = String(rows);
  }

08ポインターが近づくほど飾りが開いて線が分かれる区切り線

ポインターと飾りの中心との距離を120pxで割って目標の開き具合を作り、現在の開きは毎フレーム、目標までの残りの一部だけ近づきます。開きに応じて線の二つの片が左右へ離れ、飾りは回りながら大きくなります。仮想のポインターが左右を行き来するデモで、動いたフレームは23/23です。

ポインターが近づくほど飾りが開いて線が分かれる区切り線
新しいタブで開く

08 · ポインターが近づくほど飾りが開いて線が分かれる区切り線 — 計測 累積面積 2.326% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

NEAR_RANGEOPEN_RATEOPEN_MAX_PX
  function smooth(t) { return t * t * (3 - 2 * t); }
  function paint() {
    var half = OPEN_MAX_PX * open / 2;
    left.style.transform = 'translateX(' + (-half).toFixed(2) + 'px)';
    right.style.transform = 'translateX(' + half.toFixed(2) + 'px)';
    orn.style.transform = 'rotate(' + (open * 135).toFixed(1) + 'deg) scale(' + (0.5 + open * 0.9).toFixed(3) + ')';
    openEl.textContent = String(Math.round(open * 100));
  }
  function goalOf() {
    if (pinned) return 1;
    if (!pointer) return 0;
    var box = orn.getBoundingClientRect();
    var dx = pointer.x - (box.left + box.width / 2);
    var dy = pointer.y - (box.top + box.height / 2);
    var dist = Math.sqrt(dx * dx + dy * dy);
    return smooth(Math.min(1, Math.max(0, 1 - dist / NEAR_RANGE)));
  }

09二つのセクションが波の境目で受け渡される区切り線の場面

スクロール位置ひとつが波の位相を押し、同じ値の割合が境目の線を描き進め、上のセクションの色が下へにじむ高さも決めます。03 と同じ scroll イベントで動き、デモではコードが scrollTop をスクロールできる長さの62%まで動かします。動いたフレームは23/23です。

二つのセクションが波の境目で受け渡される区切り線の場面
新しいタブで開く

09 · 二つのセクションが波の境目で受け渡される区切り線の場面 — 計測 累積面積 25.993% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

WAVE_PHASE_PER_PXBLEED_PCTDRAW_RANGE_PX
  function edgeY(x, phase) {
    var y = BASE_Y;
    for (var i = 0; i < WAVE_AMP.length; i += 1) {
      y += WAVE_AMP[i] * Math.sin(2 * Math.PI * x / WAVE_PERIOD[i] + phase * WAVE_SPEED[i]);
    }
    return y;
  }
  function update() {
    var top = scroller.scrollTop;
    var phase = top * WAVE_PHASE_PER_PX;
    var pts = pathOf(phase);
    fill.setAttribute('d', 'M0 0H' + VIEW_W + 'V' + pts[pts.length - 1].split(' ')[1] + 'L' + pts.slice().reverse().join('L') + 'Z');
    var line = 'M' + pts.join('L');
    edge.setAttribute('d', line);
    var drawn = Math.min(1, Math.max(0, top / DRAW_RANGE_PX));
    edge.style.strokeDasharray = '1';
    edge.style.strokeDashoffset = String(1 - drawn);
    bleed.style.height = (BLEED_PCT * drawn).toFixed(1) + '%';
    phaseEl.textContent = phase.toFixed(1);
  }

値が場面を変えるところ — 消える区間・波の重ね数・反応範囲

三つの定数が、線が消える幅、境目の起伏、飾りが開く距離をほぼ決めます。表の数値は九項目をそれぞれ描画し、23個のフレーム間隔ごとに測った値です。

項目 累積面積 % 強度平均 動いたフレーム
01 両端が消える線 20.4% 54.5 23/23
02 文字幅に合わせる線 15.2% 34.0 23/23
03 スクロールで描く線 30.0% 75.1 23/23
04 サイン波の境目 11.6% 113.9 23/23
05 破れ紙の境目 0.9% 138.8 19/23
06 点の釣鐘曲線 4.8% 94.3 19/23
07 縦の区切り線 29.5% 104.8 23/23
08 飾りの開き 2.3% 159.9 23/23
09 波の受け渡し 26.0% 82.1 23/23

累積面積が10%を下回るのは 05・06・08 の三つで、動く部分が線や点と細いので面積が小さくなっています。05 と 06 が19/23なのは、デモが200msから1600msで終わってから止まるからです。

消える区間が線の濃い幅を決める

FADE_PCT が30のとき、smoothstep は区間の真ん中にあたる15%の位置で透明度が半分になります。10に減らすと線は端から10%の内側からほぼ濃くなり、50に増やすと二つの区間が真ん中で出会って一点だけが最も濃くなります。

波の重ね数が境目の起伏を決める

WAVE_LAYERS が2なら振幅の合計が25で、境目は y が37から87のあいだを往復します。3にすると合計が30になって32から92へ広がり、三つめの周期47の波は、4pxの標本間隔だと一周期に約12個(47÷4=11.75)の標本しか乗りません。

反応範囲が飾りの開き始める距離を決める

NEAR_RANGE が120のとき、ポインターが飾りから60pxの位置で目標の開きは半分、90pxでは16%ほどで、120px以上では閉じたままです。200に広げると、同じ90pxの位置でも開きは57%ほどになります(ソースの式で計算した値です)。

九つのHTMLとSCSSを丸ごと受け取って値を書き換えたいときは、パスワード ntadr22m で次の圧縮ファイルを開いてください。

どこで壊れるか — 折り返し・狭い画面・読めない区切りの文字

スライダーが50を超えると消える区間がねじれる

左の区間の終わりと右の区間の始まりが50%でぶつかるのが限界です。50を超えると色の止まる位置の順序が逆になって真ん中の見え方が崩れるので、スライダーの最大を50に止めてあります。

文字が長いと両側の線が0pxになる

02 では文字の幅に両脇の余白24pxを足した値が行の幅を超えると、二本の線の幅が0になり、文字が行の外へはみ出します。文字に white-space: nowrap を付けているためなので、狭い画面では文字を短くするか折り返しを許す必要があります。

標本の間隔より細かい波は階段になる

04 は4px間隔で点を打つので、周期が8以下の波を足すと標本が波を取りこぼして階段状になります。重ねを増やすときは、いちばん短い周期を8以上にしてください。

同じシードでも幅が違うと破れ方が変わる

05 は点の数が「シートの幅÷9」で決まるため、幅が違えば同じシードの値の並びが伸び縮みして別の形になります。再読み込みで同じ形になるのは同じ幅のときだけです。

幅が変わったのに判定を呼ばないと古い線が残る

07 は幅が変わるたびに mark() を呼んで行の先頭を判定し直します。デモでは mark() が幅を変える setWidth() の中からだけ呼ばれ、resize のリスナーはありません。そのため、ウィンドウの大きさだけが変わっても判定は更新されず、古い結果が残ります。また、項目の高さがそろっていないと同じ行でも offsetTop が違い、判定が外れます。

変形三つとアクセシビリティ

項目 定数 値 感じ
01 FADE_PCT 30 → 10 線が端近くまで濃く見えます。50にすると真ん中の一点だけが最も濃くなります
04 WAVE_LAYERS 2 → 3 周期47の細かい波が載って繰り返しが減り、振幅の合計が25から30に広がります
08 NEAR_RANGE 120 → 200 中心から200px離れたところから飾りが開き、同じ位置でもより大きく開きます

ボタンで操作する場面は 02・04・05・06・08 の五つで、01 と 07 はスライダー、03 と 09 は tabindex を付けたスクロール領域なので矢印キーや PageDown で届きます。prefers-reduced-motion が reduce のときはデモのループを回しません。01 は30のまま止まり、04 は位相0のまま止まり、05 は破れた形をすぐに描き、03 はスクロール範囲の真ん中、09 は31%(動かす範囲62%の半分)の位置に置いて一度だけ描きます。getBoundingClientRect で測った値は、02・03・08 が幅や距離の計算に使っています。

css 区切り線はborder一本で足りるか?

色と太さが一定の直線なら border 一本で足ります。ただしその一本は端を消せず、文字の幅にも合わせられず、セクションの境目を曲線にすることもできません。01・02・04 のようにその隙間が目に見える場所にだけ、計算を足せば十分です。

css 区切り線を作る前によくある質問

文字入りの区切り線は、どう作ればよいですか?

文字が挟まると二本の線が文字の幅を引いた残りを分け合うことになるので、02 のように幅を測って計算する方法が安定します。文字がいつも一種類に決まっているなら、flex で二本の線が余りの幅を同じだけ受け取るようにしておく方法でも足ります。

css 区切り線 おしゃれ に見せるには何を変えればよいですか?

線そのものより境目の形を変えます。曲線にするなら 04 のように SVG の path を組み立て、紙の切り口なら 05 のように clip-path で切ります。09 はそこへスクロールの値を足して、上のセクションの色が下へにじむようにしています。

区切り線をスクロールやポインターに反応させてもよいですか?

かまいません。03 と 09 は scroll イベント、08 はポインターの距離で動かしていて、どちらも入力がないときは最初の形のまま止まっています。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。