カード型コンポーネントの実装例

広告
このブログの記事一覧のアップデートを行ったので、それを踏まえたカード型コンポーネントの実装メモです。次の実装例の解説をしながらカード型コンポーネントの実装時のポイントを説明していきます。
例に漏れず初学者の方は置いてきぼりの内容になってしまっていることと、個人的な見解が多く含まれる俺流な内容であることはご了承ください。
実装例のカード型コンポーネントは以下のようなマークアップを行っています。
<div class="card-wrapper"> <article class="card" aria-labelledby="article1" data-href="{記事のURL}"> <h2 id="article1" class="title"> <a class="primary-link" href="{記事のURL}">{記事のタイトル}</a> </h2> <p class="category"> <span class="visually-hidden">カテゴリ:</span> <a href="{カテゴリのURL}">{カテゴリ名}</a> </p> <p class="publish"> <span class="visually-hidden">投稿日:</span> <time datetime="2024-05-02">2024.05.02</time> </p> <a class="thumbnail primary-link" href="{記事のURL}" tabindex="-1"> <img src="{サムネイルのURL}" width="800" height="450" fetchpriority="high" alt="" /> <span class="thumbnail-text">Read article<span class="visually-hidden">:{記事のタイトル}</span></span> </a> </article> ...</div>
.card-wrapper
はカードを並べるための要素、.card
以下がカード型コンポーネントとなります。
見出しを含むならsectionやarticleなどのセクショニングコンテンツでマークアップを行う
原則的に見出しを含むカード型コンポーネントであればsection
やarticle
などのセクショニングコンテンツを用いてマークアップするようにします。
今回のケースでは記事に関するコンテンツであること、また、サイトの中で完全もしくは自己完結した構造を表す要素であることからarticle
でマークアップを行っています。
<article class="card" aria-labelledby="article1" data-href="{記事のURL}"> <h2 id="article1" class="title"> <a href="{記事のURL}">記事のタイトル</a> </h2> ...</article>
支援技術のローターの「ランドマーク」からアクセスできるsection
やarticle
要素には、aria-label
もしくはaria-labelledby
属性を用いてラベリングを行うようにします。原則としてこれらの要素には見出しが含まれているためaria-labelledby
属性を使用して見出しを参照するのが良いでしょう。
<article class="card" aria-labelledby="article1" data-href="{記事のURL}"> <h2 id="article1" class="title"> <a href="{記事のURL}">記事のタイトル</a> </h2> ...</article>
ラベリングがない場合、例えばVoice Overはsection
は「セクション」article
は「記事」としか読み上げませんが、ラベリングを行うことで支援技術を使用しているユーザーが各ランドマークの内容を確認しやすくなります。

また、なんでもかんでもul
要素でマークアップしている例が技術記事含めて多く散見されますが、ul
は単に何かを並べるための要素として存在しているわけではないため、それは本当に「リスト」としてマークアップすべきかどうかは慎重に検討したほうがいいと思います。
Voice Overではnav
要素内のul
要素やol
要素を除き、list-style: none
が指定されたリスト要素を「リスト」として認識しないようになっています。これは不具合ではなく、Voice Overユーザーから寄せられた「リストが多すぎるためにリストの情報が繰り返し読み上げられるのが煩わしい」というフィードバックに起因しています。
文章構造は必ず見出し始まりにする
実装例のデザインでは以下のような順番になっています。
- サムネイル(ホバーで「Read article」が表示)
- カテゴリ
- 見出し
- 投稿日
ただし、マークアップは以下のような順番で行います。
- 見出し
- カテゴリ
- 投稿日
- サムネイル(ホバーで「Read article」が表示)
このようにデザインとマークアップの順番が異なるのには、次のような理由があります。
- 文書構造的には見出し→コンテンツの順が望ましいため
- 支援技術には見出しジャンプ機能が存在しており、デザインの順番でマークアップを行うと見出しジャンプ機能を使用するユーザーは見出しの前のコンテンツを認識できない可能性があるため
見た目はorder
プロパティやgrid
プロパティなどを使えば調整できるので、なるべくならマークアップは文書構造を優先して行うのがベターだと考えます。見出しは文書構造の基本となる重要な要素なので、必ず先頭に配置するようにしておきたいところです。
ただし、focusableな要素をCSSで並び替えるとキーボード操作によるフォーカスの順序がおかしくなってしまう故にユーザーの混乱を招く恐れがあります。この問題点に関してはreading-order-itemsプロパティが利用できるようになれば解決できますが、まだ提案段階なのでサポートされるのはしばらく先でしょう。
カテゴリ名や投稿日にはラベルを付ける
マークアップではカテゴリ名や投稿日の前に「カテゴリ:」や「投稿日:」といったラベルを付けるようにしています。
<p class="category">カテゴリ:<a href="{カテゴリのURL}">{カテゴリ名}</a></p><p class="publish">投稿日:<time datetime="2024-05-02">2024.05.02</time></p>
デザイン的には「カテゴリ:」や「投稿日:」などのラベルをつけずに「HTML」や「2024.05.02」をそのまま表示しても意味は通るでしょうが、文書構造の視点や支援技術での読み上げを考慮するとラベルはあったほうがいいと考えました。ラベルが存在しないと投稿日は察することができるかもしれませんがカテゴリについては何を示す情報なのかがわかりづらいかもしれません。
今回のケースでは各ラベルをvisually hiddenして視覚的には非表示にしつつ支援技術からは読み取れるようにしています。
<p class="category"> <span class="visually-hidden">カテゴリ:</span> <a href="{カテゴリのURL}">{カテゴリ名}</a></p><p class="publish"> <span class="visually-hidden">投稿日:</span> <time datetime="2024-05-02">2024.05.02</time></p>
.visually-hidden { position: fixed !important; inset: 0 !important; display: block !important; inline-size: 4px !important; block-size: 4px !important; padding: 0 !important; margin: 0 !important; contain: strict !important; pointer-events: none !important; visibility: visible !important; border: none !important; opacity: 0 !important;}
サムネイルの取り扱い
今回のケースではサムネイルにも記事へのリンクを指定していますが、見出し内のそれとリンクが重複しています。
<h2 id="article1" class="title"> <a href="{記事のURL}">{記事のタイトル}</a></h2>
...
<a class="thumbnail" href="{記事のURL}"> <img src="{サムネイルのURL}" width="800" height="450" fetchpriority="high" alt="" /></a>
マウスorタップ操作の場合は気にならない部分ですが、キーボード操作の場合はカードの中で同じリンク先へ2度フォーカスが移動することとなり、不要な操作が増えることになります。
そのため、サムネイルのリンクにはtabindex="-1"
を指定します。この手法はYouTubeでも用いられており、指定されたリンクはタブ移動の順番から外れるため、重複するリンクをスキップできます。
<a class="thumbnail" href="{記事のURL}" tabindex="-1"> <img src="{サムネイルのURL}" width="800" height="450" fetchpriority="high" alt="" /></a>
サムネイル画像は記事のタイトルに紐づいているのと、後述する「Read article」のテキストとの兼ね合い(『alt属性の良い事例(つけ方・書き方)|情報バリアフリーポータルサイト 事例7-3』を参照)からalt属性
は空(alt=""
)に設定します。
今回のケースではalt=""
を指定していますが、代替テキストを含めることが望ましい画像の場合は必ずalt
属性を記述するようにしてください。
また、画像の遅延読み込みのためにloading="lazy"
を指定しますが、ファーストビューに掲載されている画像の場合はLCPを悪化させるためloading="lazy"
は指定しないように注意してください。当ブログの実装ではそういった画像はfetchpriority="high"
を指定して高い優先度で画像を取得するようにしています。
<a class="thumbnail" href="{記事のURL}" tabindex="-1"> <img src="{サムネイルのURL}" width="800" height="450" fetchpriority="high" alt="" /></a>
「Read article」のテキストの取り扱い
実装例ではサムネイルにホバーがされた際に「Read article」というテキストを表示するようにしています。
<a class="thumbnail" href="{記事のURL}" tabindex="-1"> <img src="{サムネイルのURL}" width="800" height="450" fetchpriority="high" alt="" /> <span class="thumbnail-text">Read article</span></a>
ただし、この「Read article」というテキストには次のような問題点があります。
- リンクのテキストが抽象的でそのリンク先が何なのかが判断しにくい
- 支援技術のローターの「リンク」の一覧からリンク先を判別しにくい
- LighthouseのSEOの項目にて「リンクにわかりやすいテキストが設定されていません」という警告が出る
そのため、visually hiddenしたテキストで補足するようにします。
<a class="thumbnail" href="{記事のURL}" tabindex="-1"> <img src="{サムネイルのURL}" width="800" height="450" fetchpriority="high" alt="" /> <span class="thumbnail-text"> Read article <span class="visually-hidden">:{記事のタイトル}</span> </span></a>
これにより先述した問題点は解決できますが、「Read article」のラベルがページ内で重複しているため、ユーザー体験を損ねる問題は残ります。この点に関しては実装側では関与できないためデザインの段階から改める必要がある認識です。
display:gridを使用してカードを格子状に並べる
過去に投稿してそこそこの反響を得た記事『あなたが教わってるそのCSSテクニックはもう古い』でも触れましたが、カードを格子状に並べる場合はdisplay:grid
を使用します。
.card-wrapper { --max-inline-size: 1024px; --column-min-size: 16.5rem; --gap: max(16px, 2.5%);
display: block grid; grid-template-columns: repeat(auto-fill, minmax(min(var(--column-min-size), 100%), 1fr)); gap: var(--gap); max-inline-size: var(--max-inline-size); margin-inline: auto;}
display:flex
や絶滅危惧種のfloat:left
とは違い、記述量が少なく済むのに加えて子要素の最小幅を決めながら親要素の横幅に合わせてレスポンシブにカラムを切り替えることが可能となります。
カード型コンポーネントを使いまわしする場合は拡張性を持たせるために最小幅の指定やgap
の指定はカスタムプロパティを経由させると良いでしょう。
また、min(var(--column-min-size), 100%)
のようにmin()
関数で最大幅を100%にするように推奨します。これによってカードの横幅がカードを並べる要素の横幅を超えたときにはみ出すことが無くなります。
カードの高さはsubgridで揃える
過去に投稿した記事『カードの高さを揃えたければsubgridを使えばいい』で触れたようにsubgrid
を使用してカードの高さを揃えるようにします。subgrid
の取り扱いについてはそちらの記事を参照してください。
.card { --gutter: 1lh; --font-size: clamp(0.75rem, 0.705rem + 0.23vi, 0.875rem); --color-background: #fcfcfc; --color-background-active: color-mix(in srgb, var(--color-background), black 5%); --color-active: #1ca4b4; --shadow: 0 4px 10px rgb(0 0 0 / 20%); --duration: 0.3s;
display: block grid; grid-template-rows: subgrid; grid-row: span 4; row-gap: var(--gutter); padding: var(--gutter); font-size: var(--font-size); background-color: var(--color-background); transition: background-color var(--duration), box-shadow var(--duration);
&:focus-within { background-color: var(--color-background-active); box-shadow: var(--shadow); }
@media (any-hover: hover) { &:hover { background-color: var(--color-background-active); box-shadow: var(--shadow); } }}
flex-direction:column
などの従来の方法では一箇所しかコンテンツの高さを揃えることができませんでしたが、subgrid
を使えば全てのコンテンツの高さを揃えることが可能になります。
見出しのリンクの行数を制限する
今回のデザインでは見出しのリンクの行数を制限する必要はそこまでありませんが、-webkit-line-clamp
を用いて3行を超えた際に3点リーダーを付けて省略するようにします。
.title a { --limit: 3;
display: -webkit-box; block-size: min(100%, calc(1lh * var(--limit))); overflow: clip; text-overflow: ellipsis; -webkit-box-orient: block-axis; -webkit-line-clamp: var(--limit);}
block-size
の値は100%
でも問題ありませんが、省略時に内包しているsvg
の頭が見えてしまうケースが過去にあったためmin(100%, 3lh)
でブロックサイズの値を3lh
が上限としています。ちなみにlh
はline-height
と同じ長さを表す単位です。
-webkit-box-orient
の値にはvertical
ではなく論理値のblock-axis
を指定しています。Chrome系ブラウザとFirefoxでは-webkit-box-orient:block-axis
で縦書き時の正常な表示が確認できたものの、Safariでは表示に不具合が起こるため注意してください。今回の件だけでなくSafariは縦書き時の表示に難がある印象です。
見出しのリンクとサムネイルのリンクのホバーエフェクトを連動させる
同じ記事を遷移先とする見出しのリンクとサムネイルのリンクのホバーエフェクトを連動させることでリンクの役割が同じであることが判断しやすくなります。
ホバーエフェクトを連動させたい要素に一意のclass
属性(今回のケースでは.article-link
)を指定し、:has()
セレクタを使用してカードの子孫要素の.article-link
がホバー状態の時のスタイルを指定します。また、俺流hover実装例で説明した理由から:focus-visible
の時にも同様のスタイルを指定します。
<h2 id="article1" class="title"> <a class="article-link" href="{記事のURL}">{記事のタイトル}</a></h2>
...
<a class="thumbnail article-link" href="{記事のURL}" tabindex="-1"> <img src="{サムネイルのURL}" width="800" height="450" fetchpriority="high" alt="" /> <span class="thumbnail-text">Read article<span class="visually-hidden">:{記事のタイトル}</span></span></a>
.title a { --limit: 3;
display: -webkit-box; block-size: min(100%, calc(1lh * var(--limit))); overflow: clip; text-overflow: ellipsis; -webkit-box-orient: block-axis; -webkit-line-clamp: var(--limit); transition: color var(--duration);
&:is(.card:has(.article-link:focus-visible) *) { color: var(--color-active); }
@media (any-hover: hover) { &:is(.card:has(.article-link:hover) *) { color: var(--color-active); } }}
.thumbnail { display: block flow; grid-row: 1 / 2; min-inline-size: 0; aspect-ratio: 16 / 9; margin-block-start: calc(var(--gutter) * -1); margin-inline: calc(var(--gutter) * -1); contain: strict;}
.thumbnail img { width: 100%; height: 100%; object-fit: cover; transition: scale var(--duration);
&:is(.card:has(.article-link:focus-visible) *) { scale: 1.1; }
@media (any-hover: hover) { &:is(.card:has(.article-link:hover) *) { scale: 1.1; } }}
サムネイルはホバー時に拡大するようにします。実装例ではサムネイルの親要素に新しい包含ブロックの生成(position:absolute
に対するrelative
のような役割)、はみ出し防止、かつ厳格なCSS封じ込めを行いレンダリングを最適化させるcontain:strict
を採用しています。
原則的に論理プロパティを優先したスタイリングを行っていますが、横書きでも縦書きでも向きが変わらない置換要素のサイズ指定に関しては物理的な指定のほうがベターかなと思いwidth
とheight
を使用します。
また、タイムリーな話題でChrome 124にてaspect-ratio
プロパティを設定している要素が崩れるバグが発生しているようです。
今回の実装はちょうど発生条件と合致しているのでmin-inline-size:0
を指定しておくと良いでしょう。
今回のバグが無くてもflexboxのオーバーフローを防ぐ効果があるため、全称セレクタにmin-inline-size:0
を標準で指定しておくことをオススメします。ちなみにDestyle.cssを使用している場合はそちらにmin-width:0
の指定が含まれているので気にしなくても問題ないかもしれません。
.thumbnail-text { --color-text: var(--color-white); --color-background: color-mix(in srgb, var(--color-active) 80%, transparent); --shadow: 2px 2px 2px color-mix(in srgb, currentcolor 30%, transparent);
position: absolute; inset: 0; display: block grid; place-items: center; font-family: 'Open Sans', var(--font-sans); font-size: 2.5em; color: var(--color-text); text-shadow: var(--shadow); background-color: var(--color-background); opacity: 0; writing-mode: initial; transition: opacity var(--duration), scale var(--duration);
&:is(.card:has(.article-link:focus-visible) *) { opacity: 1; scale: 1.05; }
@media (any-hover: hover) { &:is(.card:has(.article-link:hover) *) { opacity: 1; scale: 1.05; } }}
「Read article」はサムネイル画像にオーバーレイして表示しますが、縦書き時に画像は向きが変わらないのにテキストだけ縦書きになる見栄えが気になったので writing-mode:initial
を敢えて明示的に指定することで急な縦書き表示が行われても整合性をとるテクニックを行っています。
見出しのリンクがフォーカスされている時はサムネイルのリンクにもフォーカスリングを表示する
冒頭でも触れましたが、focusableな要素をCSSで並び替えるとキーボード操作によるフォーカスの順序がおかしくなってしまう故にユーザーの混乱を招く恐れがあります。
実装例だと見出しの前にカテゴリリンクがgrid-row
で配置されているため、そのままだとフォーカスの順序が遡ってしまいます。そこで、法の抜け穴をつくやり方感は否めないもののカテゴリリンクの前に同じくgrid-row
で配置されていて、かつ同じ記事を遷移先とするサムネイルのリンクにもフォーカスリングを表示することで視覚的には正しい順序でフォーカスを移動させるようにしました。
.thumbnail { &:is(.card:has(.article-link:focus-visible) *) { outline: auto; }}
カードの子孫要素の.article-link
が:focus-visible
状態の時にサムネイルのリンクにoutline:auto
を指定することでブラウザデフォルトのフォーカスリングを呼び出します。
リンクの中のリンクに対応する
カード型コンポーネントにおいて、カード全体のクリックで記事へ遷移し、内包するカテゴリリンクやタグリンクのクリックではそれぞれの一覧へ遷移する…といった要件が求められることがあります。このようなリンクの中にリンクを仕込む実装は誤操作を引き起こしやすいため個人的には好みではありませんが、実現するための方法としていくつかの選択肢が考えられます。
リンクの中のリンクを実現させる方法としては次のような方法が考えられますが、どれもデメリットが存在しているため今回はJSで対応することとしました。
方法 | 諦めた理由 |
---|---|
a 要素と子要素のa 要素の間にobject 要素を仕込む | HTMLの仕様違反なので論外。 |
a 要素の疑似要素をカード全体にオーバーレイする | 実装コスト軽いが、カードのテキストを選択することが難しくなる。 |
subgrid を使用する | ややこしい実装を強いられる。リンクの読み上げを見出しだけに絞ることができない。 |
JSの実装例は次のとおりです。カード要素がクリックされた際に、クリックされた要素がa
要素以外の場合にdata-href
属性に指定されているURLへ遷移するようにしています。
<article class="card" aria-labelledby="article1" data-href="{記事のURL}"> <h2 id="article1" class="title"> <a class="primary-link" href="{記事のURL}">{記事のタイトル}</a> </h2> ...</article>
const initializeCard = (card: HTMLElement): void => { card.addEventListener('click', handleCardClick) card.querySelectorAll('a').forEach((link) => { link.addEventListener('mouseover', () => handleLinkHover(card, true)) link.addEventListener('mouseout', () => handleLinkHover(card, false)) })}
const handleCardClick = (event: MouseEvent): void => { if ((event.target as HTMLElement).closest('a')) return
const card = event.currentTarget as HTMLElement const href = card.getAttribute('data-href')
if (href) window.location.href = href}
const handleLinkHover = (card: HTMLElement, isHovered: boolean): void => { if (isHovered) { card.setAttribute('data-link-hovered', 'true') } else { card.removeAttribute('data-link-hovered') }}
document.addEventListener('DOMContentLoaded', () => { const cards = document.querySelectorAll('.card') if (cards.length === 0) return
cards.forEach((card) => { initializeCard(card) })})
カード内のa
要素がホバーされている場合はカードにdata-link-hovered
属性を付与するようにしています。これをCSS側で参照することで、カード全体がホバーされている、もしくは.article-link
要素がホバーまたはフォーカスされている際は記事リンクのホバーエフェクトを適用し、その他のリンクにホバーされている際はそのリンク独自のホバーエフェクトを適用するようにします。
.title a { /* ... */
@media (any-hover: hover) { &:is(.card:has(.article-link:hover) *, .card:not([data-link-hovered]):hover *) { color: var(--color-active); } }}
.thumbnail img { /* ... */
@media (any-hover: hover) { &:is(.card:has(.article-link:hover) *, .card:not([data-link-hovered]):hover *) { scale: 1.1; } }}
.thumbnail-text { /* ... */
@media (any-hover: hover) { &:is(.card:has(.article-link:hover) *, .card:not([data-link-hovered]):hover *) { opacity: 1; scale: 1.05; } }}
JSを使用するため先述した方法よりはパフォーマンス面で劣るものの、読み上げやテキスト選択といった機能を害することなくリンクの中のリンクを実現できます。加えてマークアップおよびCSSの設計に制約は無く、JSは各案件で使いまわしすれば良いため改修時のコストや幅広いデザインに対応する際の導入コストは総合的に見ればこちらのほうが軽い印象です。JS無効環境ではカード全体のリンクは作用しませんが、それぞれのリンクから遷移すれば問題ない認識です。
また、カード全体にクリックイベントが登録されている場合はカーソルをポインターにしておくと良いでしょう。
.card { /* ... */
@media (scripting: enabled) { &[data-href] { cursor: pointer; } }}