2024年6月にXに投稿したCSSテクニックのまとめ

広告
先月からXにCSSテクニックを定期的に投稿しているので、それのまとめです。
テキストの中央寄せだからといって text-align:center を指定したほうが良いかは考えたほうがいい
和文をtext-align:center
で中央寄せすると複数行になった際に見栄えが悪くなるケースが多いです。
inline-size:fit-content
とmargin-inline:auto
でセンタリングすることで、1行の場合は中央寄せ、複数行の場合は左寄せといった実装が可能となります。
h1 { inline-size: fit-content; margin-inline: auto;}
英文を text-align:center するなら同時に text-wrap:balance も指定しておくとよい
text-wrap
はテキストの折返しを制御するプロパティで、値にbalance
を指定すると各行の文字数が均等になるように折り返されます。
h1 { text-align: center; text-wrap: balance;}
text-wrap:balance
は新しめのプロパティで、現在では全モダンブラウザでサポートされていますがiOSは17.5からのサポートとなります。とはいえ、非対応の環境でtext-wrap:balance
の指定が悪さをすることはないのでプログレッシブ・エンハンスメントの一環で導入しても問題ないでしょう。
また、text-wrap:balance
はバランスを取るために処理負荷がかかるため、数行(環境によってバラツキあり、Chromeでは6行以下)のテキストブロックにのみ対応しています。そのため主に見出しや短いテキストブロックに指定することになるでしょう。加えてパフォーマンスに影響を与えるため、パフォーマンスをとことん重視する場合は使用を控えることも検討したほうがよいかもしれません。
個人的には日本語の見出しは前述したinline-size:fit-content
とmargin-inline:auto
、英語の見出しはtext-align:center
とtext-wrap:balance
で実装することが多いです。
アコーディオンの「+」アイコンは grid-area で重ねて実装する
アコーディオンの「+」アイコンは疑似要素で実装するケースが多いですが、それぞれの疑似要素を重ねるためにposition:absolute
を使用するよりもgrid-area
で同じカラムに重ねるほうがスマートに実装できます。
summary { display: block grid; grid-template: '. icon' / 1fr 1em; column-gap: 1em; align-items: center;
&::before, &::after { content: ''; grid-area: icon; border-block-end: 1px solid; }
&::after { rotate: 90deg; }
&:is([open] > *)::after { opacity: 0; }}
ポストではgrid-row:1 / 2
となっていますが、grid-row:1
で十分です。上記の実装例のコードのようにエリア名を明示したほうが可読性は高くなります。
また、強制カラーモードでもアイコンを表示できるようにborder
で「+」を描いています。
テキストを中央寄せ、かつ端にアイコンのデザインのボタンは display: inline-grid で実装する
下のサンプルのようにテキストを中央寄せにして、かつ右端にアイコンのデザインのボタンの実装方法は様々ですが、個人的にはinline-grid
で実装するのがスマートだと考えます。
.button { display: inline grid; grid-template-columns: 1fr auto 1fr; column-gap: 1em; align-items: center; inline-size: min(100%, 400px);
&::before { content: ''; /* 空の疑似要素を用意する */ }
&::after { content: ''; justify-self: end; /* 右寄せにするために必須 */ inline-size: 0.5em; aspect-ratio: 1; border-block-start: 1px solid; border-inline-end: 1px solid; rotate: 45deg; }}
ポイントはgrid-template-columns: 1fr auto 1fr
で疑似要素に1fr
、ラベルテキストにauto
を割り当てる点です。auto
カラムはラベルテキストの内容に応じて自動的にサイズ調整され、左右の疑似要素に指定されている1fr
カラムが等しく余白を分け合います。
fr
単位は利用可能な空間を比例配分するものなので、ラベルテキストが増えるにつれて左右の1fr
カラムが縮小します。特に::before
疑似要素は空なので、テキストが折り返されるまでにスペースを圧迫すると完全に潰れるようになります。これによりラベルテキストが複数行になった場合は左寄せのように表示されます。
次の記事がうまくまとまっている印象です。
gridのアイテムに minmax() 関数を使用する際は min() 関数を絡めておく
grid-template-columns
にminmax()
関数を指定する方法はレスポンシブを容易にするため重宝されますが、minmax(360px, 1fr)
のように最小値に固定値を指定するとグリッドコンテナ幅が360pxを下回ったときにはみ出してしまいます。
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
このような問題を回避するために、minmax()
関数を使用する際は最小値をminmax(min(<length>, 100%), 1fr)
のようにmin()関数
と組み合わせて指定するクセをつけておくと良いでしょう。この方法を採用することで、グリッドコンテナ幅が固定値を下回った場合でもアイテムの横幅が100%
に調整されるため、レイアウトの崩れを防ぐことができます。
grid-template-columns: repeat(auto-fill, minmax(min(360px, 100%), 1fr));
一次元の横並びでも grid を使ったほうが適している場合もある
「一次元のレイアウトはflex
、二次元のレイアウトはgrid
が適している」という一般的な認識がありますが、実際には一次元のレイアウトでもgrid
を使用したほうが効果的なケースが存在します。
例えばよく見かけるニュースリストの実装においてリストの行頭揃えを行う場合、flex
では各列に固定幅を設定しないと揃えることが困難ですが、subgrid
機能を活用できるgrid
を使用すれば簡単に行頭揃えを実現できます。
ul { display: block grid; grid-template-columns: max-content auto 1fr; /* 日付は改行が好ましくないので`max-content`を指定する */}
li { display: block grid; grid-template-columns: subgrid; grid-column: 1 / -1; column-gap: 24px; padding-block: 32px; border-block-end: 1px solid #dadada;}
もしもli
要素にborder
やpadding
などの装飾が不要な場合は、li
要素にdisplay:contents
を指定することでsubgrid
を使用せずとも行頭揃えが可能です。
前述したボタンやアコーディオンのアイコンの配置テクニックのように一次元の横並びでもgrid
を使ったほうが適しているケースは他にも存在するため、安易にflex
を使用するのではなく、まずはgrid
の使用を検討し、Clusterレイアウトのようにflex
の方が向いてそうなケースならflex
を使うといった思考を持っておくと良いかもしれません。
Anchor Positioningでカードの背景をホバーで追従させる
実装例のようなカードの背景をホバーで追従させる方法、かつてはJSを使用しないと実装ができませんでしたが、最近Chromeで導入されたAnchor Positioningを使用すればCSSのみで実現可能です。
Anchor Positioningは主にツールチップやPopover APIでの位置調整に用いられるイメージがありますが、こういったホバーエフェクトにも流用可能です。
.card-wrapper { position: relative;
&::after { content: ''; position: absolute; inset: anchor(--card start); z-index: -1; inline-size: anchor-size(--card inline); block-size: anchor-size(--card block); background-color: var(--_background); transition-duration: 0.3s; transition-property: inset, inline-size, block-size; }}
.card { @supports not (anchor-name: --a) { transition: background-color var(--_duration);
&:has(:any-link:hover) { @media (any-hover: hover) { background-color: var(--_background); } } }
&:is(:hover, :has(:focus-visible)) { anchor-name: --card; }}
Anchor Positioningは現在ではChromeおよびEdgeのみのサポートですが、Anchor Positioningの指定が非対応の環境で悪さをすることはないためプログレッシブ・エンハンスメントの一環で導入しても差し支えないでしょう。実装例では@supports
機能クエリを使用してAnchor Positioning非対応のブラウザでは.card
の背景色を表示させるフォールバックを行っています。
当ブログのグローバルナビゲーションおよびフッターメニューのホバーエフェクトにもAnchor Positioningが使用されています。
計算を伴う値を指定する際は答えではなく計算式を指定する
rem
やvw
のように計算を伴う値を指定する際は解答を指定するのではなく、その答えに至る計算式をそのままcalc()
関数内に記述することを推奨します。値の変更が必要になった場合にわざわざ計算をし直す必要はありませんし、後からコードを見返したときにどうしてその値になったのか?が分かりやすくなります。
僕はグローバルスコープのCSS変数にそれぞれのデザインカンプのフレームの大きさとベースのフォントサイズ、それらを基準としたpx→rem
、px→dvi(dvw)
変換用の計算式を格納しておくことが多いです。
:root { --layout-width-min: 375; /* SPのデザインカンプの横幅 */ --layout-width-max: 1280; /* PCのデザインカンプの横幅 */ --base-font-size: 16; /* デフォルトのフォントサイズ */
/* カスタムプロパティに計算式を格納すればそれ以降の指定が簡潔になる */ --fluid-ratio-min: calc(1 / var(--layout-width-min) * 100dvi); --fluid-ratio-max: calc(1 / var(--layout-width-max) * 100dvi); --rem: calc(1rem / var(--base-font-size));}
.container { /* デザインカンプの幅が375pxで、要素の内側の余白が32px */ padding-inline: calc(32 * var(--fluid-ratio-min));}
.container { /* デザインカンプの幅が1280pxで、要素の内側の余白が40px */ padding-inline: calc(40 * var(--fluid-ratio-max));}
h1 { /* 24pxをremに変換 */ font-size: calc(24 * var(--rem));}
このようなアプローチを採用することで記述量は増えるもののコードの意図がより明確になり、将来の修正や調整が容易になっています。ついでにSassの@function
なども不要になります。(Tailwindを使用する場合はプラグインを自作します)
ただしclamp()
関数の推奨値の計算は複雑なため、メタ言語やフレームワークを使用しないでCSSを書く場合はVSCodeのユーザースペニットに次のような指定を登録して呼び出すようにしています。
{ "clamp": { "prefix": "clamp", "body": [ "--_clamp-min: $1;", // 最小値 "--_clamp-max: $2;", // 最大値 "--_clamp: clamp(var(--_clamp-min) * var(--rem), (var(--_clamp-min) - (var(--_clamp-max) - var(--_clamp-min)) / (var(--layout-width-max) - var(--layout-width-min)) * var(--layout-width-min)) * var(--rem) + (var(--_clamp-max) - var(--_clamp-min)) / (var(--layout-width-max) - var(--layout-width-min)) * 100dvi, var(--_clamp-max) * var(--rem));", "$3: var(--_clamp);" ] }}
Font-size Clamp Generatorのようなジェネレーターもありますが、値の追加や変更をする場合に一々ジェネレーターを開くのは面倒くさいのでこのようなアプローチを取っています。
意図が分かりにくい数値を指定する場合はCSS変数で数値の意味を説明する
CSSの構造上key:value
の形で自然と意味が伝わる場合は必ずしも全ての数値をCSS変数で説明する必要はありませんが、数値の意図が分かりにくい場合はローカルスコープのCSS変数を活用して数値の意味を説明するようにしています。
例えばポストのような角丸のデザインを実装する場合はアウターかインナーのどちらかのborder-radius
の値を基準として選び、そのborder-radius
とpadding
をローカルスコープの変数に定義します。基準としない方の角丸はcalc()
関数を使用して基準値から導出するようにします。
.card { --_padding: 8px; --_outer-radius: 24px; --_inner-radius: calc(var(--_outer-radius) - var(--_padding));
padding: var(--_padding); border-radius: var(--_outer-radius);
& > * { border-radius: var(--_inner-radius); }}
フォームの入力欄のユーザー体験を向上させる field-sizing:content は導入したほうがいい
以前投稿した記事でも紹介しましたが、入力欄の大きさを内容に応じて調整することができるfield-sizing:content
は現状Google ChromeとMicrosoft Edgeのみの対応ではあるもののプログレッシブ・エンハンスメントの一環として導入する価値はあります。
textarea { --_min-rows: 5; /* デフォルトの行数 */ --_max-rows: 20; /* 最大行数 */ --_padding: 1em;
box-sizing: border-box; inline-size: 100%; min-block-size: calc(var(--_min-rows) * 1lh + var(--_padding) * 2); max-block-size: calc(var(--_max-rows) * 1lh + var(--_padding) * 2); padding: var(--_padding); field-sizing: content;
@supports (field-sizing: content) { resize: none; /* field-sizing有効時にはリサイズ機能を無効にする */ }}
ラジオボタンやチェックボックスを装飾する場合は input 要素に直接スタイルをあてる
ラジオボタンやチェックボックスの装飾を求められるケースは多くありますが、accent-color
で対応できないそれらのデザインを実装する場合、input
要素を非表示にして空のspan
要素やラッパーのlabel
要素の疑似要素で代替する方法が一般的でした。
しかし、現代ではtype="radio"
およびtype="checkbox"
に限り、input
要素にappearance:none
を指定することで疑似要素が使用可能な空の要素として扱えるようになり、直接スタイリングが可能になります。
[type='checkbox' i] { display: inline flow-root; /* 幅と高さの指定を効くようにするため`inline-block`などの値を指定する */ appearance: none; /* チェックボックスのスタイル */
&:checked::before { /* チェックボックスがアクティブ時のスタイル */ }}
has()
や隣接セレクタを使用する必要がないため記述量を減らせるinput
要素の非表示の方法によってはTab移動での選択が不可能になってしまうが、そういった事故を防げるinput
要素のフォーカスリングが可視化される
といった理由から極力input
を隠す従来の実装は避けたほうがいいでしょう。
注意点としては縦横比が決まっている場合でもinput
にaspect-ratio
を指定するとSafariで潰れて表示されてしまうため、縦幅と横幅は明示しておくことを推奨します。
[type='checkbox' i] { inline-size: 1lh; aspect-ratio: 1;}
[type='checkbox' i] { inline-size: 1lh; block-size: 1lh;}
以下は前回の記事で触れたinput[type="checkbox"]
要素のswitch
属性を使用したスイッチUIの実装例です。強制カラーモード対応周りなど、前回の記事の実装例より少しアップデートしています。
[switch] { --_size-unit: 1lh; --_background: #dadada; --_foreground: #fcfcfc; --_duration: 0.15s;
display: inline flex; inline-size: calc(var(--_size-unit) * 2); block-size: var(--_size-unit); border: 2px solid transparent; /* 強制カラーモード対応のために`padding`ではなく`border`で余白をつくる */ border-radius: calc(1px / 0); background-color: var(--_background); vertical-align: middle; appearance: unset; transition: background-color var(--_duration);
&::before { content: ''; flex: var(--_checked, 0); transition: flex var(--_duration) linear; }
&::after { content: ''; flex-shrink: 0; block-size: 100%; aspect-ratio: 1; border-radius: inherit; background: linear-gradient(var(--_foreground) 0 0), CanvasText; /* 強制カラーモードでも視認できる背景色として扱うようにする */ }
&:checked { --_checked: 1; --_background: oklch(60% 0.4 240deg); }}
実装例ではflex
で空の疑似要素を収縮させてスライドアニメーションを行っています。grid-template-columns
を使うとよりスマートに実装できますが、Safariでハンドル部分のblock-size:100%
がうまく動かないのでこのようなアプローチをとっています。
見出しやリストのアイコンを中央寄せする場合に align-items:center を指定したほうが良いかは考えたほうがいい
特にリストのアイコンに対してですが、アイコンをalign-items:center
で実装して複数行になった際に不格好になっているケースが散見されます。
行に対してセンタリングしたい場合はアイコンにmargin-block: calc((1lh - <アイコンのサイズ>)) / 2)
を指定すれば実現できるので、こちらの方法も検討すると良いでしょう。
また、個人的にはリストコンポーネントはspan
やstrong
が使用されるケースに備えてflex
やgrid
は使わずにposition:absolute
やfloat
でアイコンの位置調整をするケースが多いです。
li { --_icon-size: 1em; --_gap: 1em; --_icon-offset: calc(var(--_icon-size) + var(--_gap));
display: block flow-root; padding-inline-start: var(--_icon-offset);
&::before { content: ''; float: inline-start; /* floatが指定された要素は`display:block`として扱われるので明示せずとも`margin-block`が使用できる */ clip-path: var(--shape-star); block-size: var(--_icon-size); aspect-ratio: 1; margin-block: calc((1lh - var(--_icon-size)) / 2); margin-inline-start: calc(var(--_icon-offset) * -1); /* `float`した要素は`text-indent`で動かせない */ background: linear-gradient(currentColor 0 0), CanvasText; /* 強制カラーモードでも視認できる背景色として扱うようにする */ }}
::marker
疑似要素はSafariで画像が使用できなかったり位置調整がやりにくいなどのデメリットが目立つので使用するケースは少ないです。
1行を均等割り付けしたいなら text-align-last:justify を指定する
見出しを均等割り付けする方法はかつては一文字ずつspan
で囲ってjustify-content:space-between
するという苦労が必要でしたが、現在ではtext-align-last:justify
を指定するだけで1行を均等割り付けすることが可能です。
th { text-align-last: justify;}
text-align-last
はブロックの最後の行もしくは強制的な改行の直前の行をどのように配置するかを設定するプロパティです。故に複数行になった場合は破綻してしまうのでその点は注意してください。
calc() 関数内でゼロ除算するとどうなるか?
算数のテストにおけるゼロ除算の解答に関して先月話題になっていましたが、CSSのcalc()
関数内でゼロ除算を行うとinfinity
として扱われます。
「これはバグなんじゃないか?」とコメントを頂きましたが、これはバグではなく仕様であり、0以外の数値をゼロ除算すると標準の符号規則に従って +∞ または -∞ が生成されると仕様書には記されています。
Dividing a value by zero produces either +∞ or −∞, according to the standard sign rules.
ゼロ除算の有効活用としては border-radius: calc(1px / 0)
は border-radius: calc(infinity * 1px)
と同じように扱われるため絶対に破綻しない角丸を実装できること、あとは z-index: calc(1 / 0)
で上限値の2147483647
と同等になるため同一レイヤー内であれば原則的に最前面に表示させることができるという活用法でしょう。
calc()
関数内でinfinity
を参照する方法と比較した時のメリットはゼロ除算のほうがタイプ数が少なくなる点、デメリットはCSSの仕様に精通していないとハックを疑われるという点でしょうか。
単色背景を親要素の幅を超えて画面いっぱいに表示したいなら border-image を使ったほうが良い
親要素の幅を超えて画面いっぱいに表示させる方法はmargin-inline: calc(50% - 50vi)
が有名ですが、この方法は横スクロールを発生させたり、コンテンツを親要素の幅に戻す処理を行う必要があったりとデメリットも目立ちます。
単純に単色背景を親要素の幅を超えて画面いっぱいに表示させたいなら横スクロールに影響を及ぼさないborder-image
で実装するとベターでしょう。
section { --_background: #1c1c1c;
border-image: linear-gradient(var(--_background) 0 0) fill 0 / /0 100lvi;}
border-image
で単色背景を指定する場合はlinear-gradient()
などのグラデーション関数を使用します。最短の記述でlinear-gradient(<color> 0 0)
と指定すると単色になります。
Sassを使用している方はコメント扱いされないように連続するスラッシュの間に半角スペースを含めておきましょう。
ホバー時の opacity の指定は避けたほうがいい
ホバーしたときに背景色を透過する実装はよく見かけますが、考えなしにopacity
を指定してしまうと文字含めて全てが透過してしまいます。もしもホバー時に背景色を透過することが望まれる場合はcolor-mix()
関数などを使用して背景色だけ透過することを推奨します。
.button { background-color: var(--_background);
&:hover { @media (any-hover: hover) { opacity: 0.8; } }}
.button { background-color: color-mix(in sRGB, var(--_background) var(--_opacity, 100%), transparent);
&:hover { @media (any-hover: hover) { --_opacity: 80%; } }}
ただし、ホバー時に透過するという実装自体が「意識させたいはずのものを目立たなくさせる」という本末転倒感あるアプローチのため、なるべくなら代替案を考えたほうが良いかもしれません。color-mix()
関数を使用したこの実装は色を暗くするor明るくするといったアプローチにも流用可能なため、そちらも考慮するとよいでしょう。
.button { background-color: color-mix(in sRGB, var(--_background), #000 var(--_darken, 0%));
&:hover { @media (any-hover: hover) { --_darken: 20%; } }}
.button { background-color: color-mix(in sRGB, var(--_background), #fff var(--_lighten, 0%));
&:hover { @media (any-hover: hover) { --_lighten: 20%; } }}
なお、昨日リリースされたFirefox 128より相対カラー構文が全モダンブラウザでサポートされたので、こちらの使用も検討してみても良いでしょう。
Scroll-driven Animations で途中横スクロールするセクションを実装する
ギャラリーサイトでよく見かける途中で横スクロールするセクションの実装、GSAPのScrollTriggerで実装されるケースが殆どですが、現代ではScroll-driven Animationsを使用すればCSSのみで実装できます。Scroll-driven AnimationsはまたしてもChromeおよびEdgeのみのサポートですがScroll-timeline Polyfillを導入すれば非サポートのSafariやFirefoxでも動作します。
.horizontal { --_scroll-amount: 500;
overflow: clip; min-block-size: calc(var(--_scroll-amount) * 1dvb); view-timeline-name: --horizontal-section;}
.container { position: sticky; inset-block-start: 0; inline-size: calc(var(--_scroll-amount) / 2 * 1dvmax); min-block-size: 100dvb; animation: linear horizontal-scroll both; animation-timeline: --horizontal-section; animation-range: contain 0% contain 100%;}
@keyframes horizontal-scroll { to { translate: calc(-100% + 100dvi); }}
難点としては横スクロールする要素に固定値の幅を指定しないといけないということ。故に内容物に合わせてスクロール量を決定するのが難しいため、後述するパララックスとは違いScrollTriggerで実装したほうがベターな気がします。
Scroll-driven Animations でパララックスを実装する
こちらもScroll-driven Animationsを使用したアニメーションの紹介。パララックスに関しては非常に少ない記述量で実現できます。こちらもPolyfillに対応。
.parallax { --_translate: 200px;
animation: parallax linear both; animation-timeline: scroll(root);}
@keyframes parallax { from { translate: 0 calc(var(--_translate) * -1); }
to { translate: 0 var(--_translate); }}
記述量の少なさ、アニメーションをCSSで完結できる、JSを使用した実装と比較してコストがかなり低いなどといった理由から、ゴリゴリのアニメーションではなく部分的にパララックスを導入したい場合はPolyfillをアニメーションライブラリのように扱ってScroll-driven Animationで実装したほうがよいかもしれません。
Scroll-driven Animations と @property ルールでページのスクロール量をカウントアップする表示を実装する
当ブログの左下にスクロールインジケーターを導入しましたが、こちらもScroll-driven Animationsを使用して実装しています。また、カウントアップに関しては@property
ルールでカスタムプロパティを定義することで実現しています。
<div class="progress"> <svg viewBox="0 0 100 100"> <circle cx="50" cy="50" r="40"></circle> </svg></div>
/* プログレスサークルの親要素 */.progress { display: block grid; grid-template-areas: 'stack'; place-items: center; inline-size: 200px; aspect-ratio: 1; counter-set: increment-counter var(--_increment); font-variant-numeric: tabular-nums; animation: increment-counter linear; animation-timeline: scroll(root);
@supports not (animation-timeline: scroll(root)) { display: none; }
&::after, & > * { grid-area: stack; }
&::after { content: counter(increment-counter) '%'; }
& svg { --_diameter: 80; /* 直径 */ --_circumference: calc(var(--_diameter) * 3.14); /* 円周 */ --_dashoffset: calc(var(--_circumference) - (var(--_increment) / 100) * var(--_circumference));
inline-size: 100%; block-size: 100%; fill: none; stroke: currentColor; stroke-dasharray: var(--_circumference); stroke-dashoffset: var(--_dashoffset); stroke-width: 5; rotate: -90deg; }}
/* @propertyでCSS変数を定義することでテキストのカウントアップが可能に */@property --_increment { syntax: '<integer>'; inherits: true; initial-value: 0;}
@keyframes increment-counter { to { --_increment: 100; }}
@property
ルールは昨日リリースされたFirefox 128により全モダンブラウザでサポートされました(iOSは16.4よりサポート)。CSS変数に型定義ができるだけでなく、background-image
やborder-image
といった本来アニメーションできなかったプロパティもアニメーションできるようになったり、@keyframes
内でカスタムプロパティの定義を行えるようになったりとアニメーションの幅が広がったと言えます。
しかし、いくつかバグが存在するようで、このスクロールインジケーターにおいてもSafariだとカウントアップが動かず、Firefoxではstroke-dashoffset
のアニメーションがうまくいかないことを観測しているのでScroll-driven Animations非対応のブラウザでは@supports
機能クエリで非表示にしています。
これらの投稿が良かったという方はXのフォローをよろしくおねがいします!