Patterns

subgridで高さを揃えるカード型コンポーネントの実装例

このブログの記事一覧のアップデートを行ったので、それを踏まえたカード型コンポーネントの実装メモです。次の実装例の解説をしながらカード型コンポーネントの実装時のポイントを説明していきます。

例に漏れず初学者の方は置いてきぼりの内容になってしまっていることと、個人的な見解が多く含まれる俺流な内容であることはご了承ください。

この記事は2024年5月時点の当ブログの実装をもとにした内容です。現在の当ブログの記事一覧は、カード型ではなくリスト型に作り直しています。

また、記事の中で「提案段階」と紹介しているreading-order-itemsプロパティは、reading-flowプロパティとreading-orderプロパティとして仕様がまとめられ、Chrome 137でサポートされました(2026年11月時点でFirefoxとSafariは未対応)。

カード型コンポーネントの実装例
<div class="card-wrapper">
  <article class="card" aria-labelledby="article1" data-href="https://www.tak-dcxi.com/article/accordion-implementation-example-using-the-name-attribute-of-details" data-blank>
    <h2 id="article1" class="title">
      <a class="article-link" href="https://www.tak-dcxi.com/article/accordion-implementation-example-using-the-name-attribute-of-details" target="_blank" rel="external">
        details要素のname属性を使用した排他的なアコーディオンの実装例
      </a>
    </h2>
    <p class="category">
      <span class="visually-hidden">カテゴリ:</span>
      <a href="https://www.tak-dcxi.com/articles/html/" target="_blank" rel="external">HTML</a>
    </p>
    <p class="publish">
      <span class="visually-hidden">投稿日:</span>
      <time datetime="2024-04-30">2024.04.30</time>
    </p>
    <a class="thumbnail article-link" href="https://www.tak-dcxi.com/article/accordion-implementation-example-using-the-name-attribute-of-details" target="_blank" rel="external" tabindex="-1">
      <img src="https://picsum.photos/800/450?random=1" width="800" height="450" decoding="async" fetchpriority="high" alt="" />
      <span class="thumbnail-text">Read article<span class="visually-hidden">: details要素のname属性を使用した排他的なアコーディオンの実装例</span></span>
    </a>
  </article>
  <article class="card" aria-labelledby="article2" data-href="https://www.tak-dcxi.com/article/scroll-linked-animation-implementation-examples" data-blank>
    <h2 id="article2" class="title">
      <a class="article-link" href="https://www.tak-dcxi.com/article/scroll-linked-animation-implementation-examples" target="_blank" rel="external">
        スクロール連動アニメーションの実装例
      </a>
    </h2>
    <p class="category">
      <span class="visually-hidden">カテゴリ:</span>
      <a href="https://www.tak-dcxi.com/articles/css/" target="_blank" rel="external">CSS</a>
    </p>
    <p class="publish">
      <span class="visually-hidden">投稿日:</span>
      <time datetime="2024-04-23">2024.04.23</time>
    </p>
    <a class="thumbnail article-link" href="https://www.tak-dcxi.com/article/scroll-linked-animation-implementation-examples" target="_blank" rel="external" tabindex="-1">
      <img src="https://picsum.photos/800/450?random=2" width="800" height="450" decoding="async" fetchpriority="high" alt="" />
      <span class="thumbnail-text">Read article<span class="visually-hidden">: スクロール連動アニメーションの実装例</span></span>
    </a>
  </article>
  <article class="card" aria-labelledby="article3" data-href="https://www.tak-dcxi.com/article/implementation-example-of-a-modal-created-using-the-dialog-element" data-blank>
    <h2 id="article3" class="title">
      <a class="article-link" href="https://www.tak-dcxi.com/article/implementation-example-of-a-modal-created-using-the-dialog-element" target="_blank" rel="external">
        dialog要素を使用したモーダルウィンドウの実装例
      </a>
    </h2>
    <p class="category">
      <span class="visually-hidden">カテゴリ:</span>
      <a href="https://www.tak-dcxi.com/articles/" target="_blank" rel="external">JavaScript</a>
    </p>
    <p class="publish">
      <span class="visually-hidden">投稿日:</span>
      <time datetime="2024-04-12">2024.04.12</time>
    </p>
    <a class="thumbnail article-link" href="https://www.tak-dcxi.com/article/implementation-example-of-a-modal-created-using-the-dialog-element" target="_blank" rel="external" tabindex="-1">
      <img src="https://picsum.photos/800/450?random=3" width="800" height="450" decoding="async" fetchpriority="high" alt="" />
      <span class="thumbnail-text">Read article<span class="visually-hidden">: dialog要素を使用したモーダルウィンドウの実装例</span></span>
    </a>
  </article>
</div>
.card-wrapper {
  --max-inline-size: 1024px;
  --column-min-size: 20rem;
  --gap: max(16px, 2.5%);

  display: 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;
}

.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: 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 (scripting: enabled) {
    &[data-href] {
      cursor: pointer;
    }
  }

  @media (any-hover: hover) {
    &:hover {
      background-color: var(--color-background-active);
      box-shadow: var(--shadow);
    }
  }
}

.title {
  font-size: 1em;
}

.title a {
  display: -webkit-box;
  block-size: min(100%, 3lh);
  overflow: clip;
  text-overflow: ellipsis;
  -webkit-box-orient: block-axis;
  -webkit-line-clamp: 3;
  transition: color var(--duration);

  &:where(.card:has(.article-link:focus-visible) *) {
    color: var(--color-active);
  }

  @media (any-hover: hover) {
    &:where(.card:has(.article-link:hover)
        *, .card:not([data-link-hovered]):hover *) {
      color: var(--color-active);
    }
  }
}

.category {
  grid-row: 2 / 3;
}

.category a {
  --color-decoration: color-mix(in srgb, currentcolor 20%, white);

  display: inline-block;
  padding-block: 0.1em;
  padding-inline: 1em;
  border: 1px solid var(--color-decoration);
  border-radius: calc(infinity * 1px);
  transition: background-color var(--duration), color var(--duration);

  &:focus-visible {
    color: var(--color-active);
    background-color: var(--color-decoration);
  }

  @media (any-hover: hover) {
    &:hover {
      color: var(--color-active);
      background-color: var(--color-decoration);
    }
  }
}

.thumbnail {
  display: block;
  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;

  &:where(.card:has(.article-link:focus-visible) *) {
    outline: auto;
  }
}

.thumbnail img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: scale var(--duration);

  &:where(.card:has(.article-link:focus-visible) *) {
    scale: 1.1;
  }

  @media (any-hover: hover) {
    &:where(.card:has(.article-link:hover)
        *, .card:not([data-link-hovered]):hover *) {
      scale: 1.1;
    }
  }
}

.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: 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);

  &:where(.card:has(.article-link:focus-visible) *) {
    opacity: 1;
    scale: 1.05;
  }

  @media (any-hover: hover) {
    &:where(.card:has(.article-link:hover)
        *, .card:not([data-link-hovered]):hover *) {
      opacity: 1;
      scale: 1.05;
    }
  }
}

.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;
}

@layer defaults {
  :root {
    --color-white: #fcfcfc;
    --color-smoke: #dadada;
    --color-black: #353535;
    --font-sans: "Helvetica Neue", arial, "Hiragino Kaku Gothic ProN",
      "Hiragino Sans", "BIZ UDPGothic", meiryo, sans-serif;
  }

  *,
  ::before,
  ::after {
    box-sizing: border-box;
    margin: 0;
  }

  body {
    padding: max(5vi, 20px);
    font-family: var(--font-sans);
    color: var(--color-black);
    background-color: var(--color-smoke);
    line-height: 1.5;
  }

  a {
    color: unset;
    text-decoration: unset;
  }
}
const initializeCard = (card) => {
  card.addEventListener("click", handleCardClick);
  card.querySelectorAll("a").forEach((link) => {
    link.addEventListener("mouseover", () => handleLinkHover(card, true));
    link.addEventListener("mouseout", () => handleLinkHover(card, false));
  });
};

const handleCardClick = (event) => {
  if (event.target.closest("a")) {
    return;
  }

  const card = event.currentTarget;
  const href = card.getAttribute("data-href");
  if (!href) return;

  const blank = card.hasAttribute("data-blank");
  if (blank) {
    window.open(href, "_blank");
  } else {
    window.location.href = href;
  }
};

const handleLinkHover = (card, isHovered) => {
  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);
  });
});

マウスかキーボードでコードを選ぶと編集でき、表示結果に反映されます。編集中は Tab キーで字下げし、Esc キーでエディターから抜けます。

実装例のカード型コンポーネントは以下のようなマークアップを行っています。

<div class="card-wrapper">
	<article class="card" aria-labelledby="article1" data-href="{記事のURL}">
		<h2 id="article1" class="title">
			<a class="article-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 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>
	</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ユーザーから寄せられた「リストが多すぎるためにリストの情報が繰り返し読み上げられるのが煩わしい」というフィードバックに起因しています。

文章構造は必ず見出し始まりにする

実装例のデザインでは以下のような順番になっています。

  1. サムネイル(ホバーで「Read article」が表示)
  2. カテゴリ
  3. 見出し
  4. 投稿日

ただし、マークアップは以下のような順番で行います。

  1. 見出し
  2. カテゴリ
  3. 投稿日
  4. サムネイル(ホバーで「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して視覚的には非表示にしつつ支援技術からは読み取れるようにしています。

ラベルをvisually hiddenしたspanで囲む
<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はAMPのものを流用
.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でも用いられており、指定されたリンクはタブ移動の順番から外れるため、重複するリンクをスキップできます。

サムネイルのリンクにはtabindex="-1"を指定する
<a class="thumbnail" href="{記事のURL}" tabindex="-1">
	<img src="{サムネイルのURL}" width="800" height="450" fetchpriority="high" alt="" />
</a>

投稿カードのUXを良くするための工夫|えび🍤

僕が書いたHTML/CSSのコードを皆さんに見てもらって、改善できる箇所を見つけたりナレッジの共有をする「このデザインどうマークアップする?」シリーズの第二弾です! このデザインどうマークアップする? 早速ですが、このデザインどうやってマークアップしますか? 仕様としては「CSS」…

note.com

サムネイル画像は記事のタイトルに紐づいているのと、後述する「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したテキストで補足するようにします。

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」のラベルがページ内で重複しているため、ユーザー体験を損ねる問題は残ります。この点に関しては実装側では関与できないためデザインの段階から改める必要がある認識です。

アクセシビリティ通信 #1 テキストリンクの指定 | grip on minds

シリーズ「アクセシビリティ通信」ではウェブアクセシビリティに関するトピックを取り上げていきます。今回はテキストリンクの指定について取り上げます。

griponminds.jp

display:gridを使用してカードを格子状に並べる

過去に投稿してそこそこの反響を得た記事『あなたが教わってるそのCSSテクニックはもう古い』でも触れましたが、カードを格子状に並べる場合はdisplay:gridを使用します。

カードを並べる要素のCSS
.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の取り扱いについてはそちらの記事を参照してください。

カードの親要素のCSS
.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の時にも同様のスタイルを指定します。

連動させたい要素に.article-linkを指定
<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の指定が含まれているので気にしなくても問題ないかもしれません。

リセットCSSにmin-inline-size:0標準搭載してたから気づかなかったけどこんなバグあったんだなぁ

display:flexのオーバーフロー防げるし全人類リセットCSSにmin-inline-size:0標準搭載した方がいいよ https://t.co/deSF07Z8FU

ポストを別枠で表示する

Read articleのテキストのスタイル
.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へ遷移するようにしています。

data-href属性を付与する
<article class="card" aria-labelledby="article1" data-href="{記事のURL}">
	<h2 id="article1" class="title">
		<a class="article-link" href="{記事のURL}">{記事のタイトル}</a>
	</h2>
	...
</article>
JSの実装例

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無効環境ではカード全体のリンクは作用しませんが、それぞれのリンクから遷移すれば問題ない認識です。

また、カード全体にクリックイベントが登録されている場合はカーソルをポインターにしておくと良いでしょう。

JS有効かつdata-href属性が存在する場合はカーソルをポインターにする
.card {
	/* ... */

	@media (scripting: enabled) {
		&[data-href] {
			cursor: pointer;
		}
	}
}

参考リンク