作例 / ウェブサイト

花屋さんのサイトを、
読み解く。

街角の花屋「ひだまりフラワー」の 1 ページサイトを題材に、ガイドで学んだ HTML・CSS・JavaScript が、実際のサイトでどう組み合わさるかを見ていきます。各章から、ガイドの該当する節へ戻れます。

目次を開く(全 14 章)

作例のコードには、初心者向けのコメントを多めに書いてあります。このページで要点を掴んだら、エディターで hidamari-flower フォルダーを開き、コメントと合わせて読んでください。

01

完成したサイトを見る

ひだまりフラワーは、架空の花屋の紹介サイトです。1 ページの中に、お店の紹介、商品、注文の流れ、料金、写真、よくある質問、お問い合わせが上から順に並んでいます。

ひだまりフラワーのトップ画面。左に「日常に、小さな「ひだまり」を。」という見出しとボタン、右に店先の写真と、少し傾いた紹介カードが並ぶ。
パソコンの幅で開いたところ。見出しと写真が左右に並びます。
スマートフォンの幅で開いたトップ画面。見出し、ボタン、写真が縦一列に並ぶ。
スマートフォンの幅では、同じ内容が縦一列に並びます。

hidamari-flower/index.html をダブルクリックすると、ブラウザで開けます。サーバーは要りません。ただし、地図とお問い合わせの送信は外部のサービスを使うので、インターネットにつながっている必要があります。

ブラウザの幅を少しずつ狭めながら見ると、どこで並び方が変わるかが分かります。その仕組みは 06 章と 09 章で見ます。

02

ファイルの役割を知る

HTML は内容と構造、CSS は見た目、JavaScript は動き、と役割ごとにファイルを分けています。直したい所があるとき、どのファイルを開けばよいかが名前から分かります。

フォルダーの中身
hidamari-flower/
├── index.html          … ページの本体(内容と構造)
├── thanks.html         … お問い合わせを送ったあとに表示するページ
├── stylesheets/
│   ├── style.css       … CSS の入口。下の 4 つを読み込む
│   ├── base.css        … リセット・色などの変数・要素の土台・並べ方
│   ├── components.css  … ボタン・カードなど、何度も使う部品
│   ├── sections.css    … セクションごとの見た目
│   └── utilities.css   … スクロールの演出・印刷用の指定
├── javascripts/
│   └── main.js         … メニューの開閉、料金の計算、写真の拡大など
└── images/             … 写真とロゴ(WebP 形式)

index.html の <head> には、ページの説明文、スマートフォンのアドレスバーの色、SNS で共有したときの題名(OGP)、タブのアイコンをまとめて書いています。CSS と JavaScript を読み込むのも、ここです。

index.html(head の抜粋)
<!-- 検索結果に表示される、ページの説明文 -->
<meta name="description" content="ひだまりフラワーは、季節の切り花や花束、植物と自然素材の鉢カバーを揃えた街角の花屋です。一輪から気軽にお立ち寄りください。">
<!-- スマートフォンのブラウザで、アドレスバーなどに使われる色 -->
<meta name="theme-color" content="#416b49">
<!-- SNS でページを共有したときに表示される題名と説明(OGP) -->
<meta property="og:title" content="ひだまりフラワー|日常に、小さな「ひだまり」を。">
…
<!-- 見た目は style.css にまとめて書く -->
<link rel="stylesheet" href="stylesheets/style.css">
<!-- defer を付けると、HTML を最後まで読み込んでから JavaScript が実行される -->
<script src="javascripts/main.js" defer></script>

03

意味のあるタグで骨組みを作る

見た目を整える前に、内容に合ったタグで骨組みを作っています。どこが見出しで、どこが本文で、どこがメニューかを、ブラウザや読み上げソフトが正しく受け取れるようにするためです。

index.html(全体の骨組み)
<a class="skip-link" href="#main">本文へスキップ</a>

<header class="site-header" id="header">
  …ロゴとメニュー(nav)…
</header>

<main id="main">
<section class="hero" id="hero">…h1 はここに 1 つだけ…</section>
<section class="section" id="about">…</section>
…
</main>

<footer class="site-footer dark">…</footer>

セクションの中身も、内容に合わせてタグを選んでいます。

内容使ったタグ選んだ理由
花束を選ぶ流れ<ol>手順の順番に意味があるので、番号付きのリストにする
料金の表<table> <caption> scope表の題名と、見出しが行か列かを伝える
営業時間と定休日<dl> <dt> <dd>「項目名と値」の組を並べる
よくある質問<details> <summary>押すと開閉する。JavaScript が要らない
商品のカード<article>1 枚ずつが独立した内容のまとまり

よくある質問は、同じ name を付けた <details> を並べています。こうすると、1 つを開いたときにほかが自動で閉じます。

index.html(よくある質問)
<div class="faq-list" data-reveal>
  <details class="card faq" name="faq" open><summary>一輪だけでも買えますか?</summary><p>もちろんです。お部屋の雰囲気や飾る場所に合う一輪を、一緒に選びましょう。</p></details>
  <details class="card faq" name="faq"><summary>花束の予算が決まっていません。相談できますか?</summary>…</details>
  …
</div>

アイコンは、ページの先頭で <symbol> として一度だけ定義し、使う場所で <use href="#i-flower"/> のように呼び出しています。線の色を currentColor にしてあるので、アイコンの色は CSS の color で変えられます。

誰でも使えるようにする工夫

Tab キーを押すと、左上に「本文へスキップ」が現れます。キーボードで操作する人が、メニューを飛ばして本文へ移るためのリンクです。飾りのアイコンには aria-hidden="true" を付け、読み上げの対象から外しています。

04

CSS を役割ごとに分け、層で順番を決める

CSS は 4 つのファイルに分け、入口の style.css から読み込んでいます。そして @layer で、どの指定を優先するかを先に宣言しています。

style.css
@layer reset, tokens, base, layout, components, sections, utilities;

@import url("base.css");         /* reset・tokens・base・layout */
@import url("components.css");   /* ボタン・カードなどの共通部品 */
@import url("sections.css");     /* ページの各セクション */
@import url("utilities.css");    /* 表示の演出・印刷 */

@layer の宣言では、後ろに書いた層ほど強くなります。層の強さは、セレクタの詳細度よりも先に比べられます。たとえば sections 層の指定は、components 層の同じ指定に必ず勝ちます。そのため、セレクタを長くして詳細度を上げる必要がありません。

層書いてあるものファイル
resetブラウザごとの差をそろえるbase.css
tokens色・文字・余白の変数(05 章)
basebody や見出しなど、要素そのものの見た目
layout幅をそろえる入れ物と、並べるためのグリッド(06 章)
componentsボタン・カード・アイコンなどの部品components.css
sectionsセクションごとの見た目sections.css
utilitiesスクロールの演出・印刷(いちばん強い)utilities.css

いちばん弱い reset 層では、さらに :where() で詳細度を 0 にしています。あとから書くどの指定でも上書きできる土台にするためです。

base.css(reset の抜粋)
@layer reset {
  :where(body, h1, h2, h3, p, figure, dl, dd, ul, ol, fieldset) {
    margin: 0;
    padding: 0;
  }

  :where(img, svg) {
    display: block;
    max-inline-size: 100%;
    block-size: auto;
  }

  /* 上の display: block に負けないよう、hidden 属性の付いた要素は必ず隠す */
  [hidden] { display: none; }
}
リセットが hidden を打ち消す

hidden 属性で要素が隠れるのは、ブラウザ標準の [hidden] { display: none; } のおかげです。この標準の指定は、自分で書いた CSS に必ず負けます。:where(img, svg) { display: block; } を書くと、アイコン置き場の <svg hidden> が表示されてしまうので、最後の 1 行で隠し直しています。

部品の CSS は、ネスト記法で書いています。.btn { svg { … } } は「.btn の中の svg」、&:hover は「.btn 自身にマウスが乗ったとき」です。

@import の読み込み

@import で読み込むファイルは、style.css を読んだあとに取りに行きます。ファイルがたくさんある大きなサイトでは、そのぶん表示が遅れることがあります。このサイトの規模なら、気になるほどの差はありません。

05

色と余白を変数に集める

サイトで使う色・文字・余白・角丸・影は、base.css の tokens 層にカスタムプロパティとしてまとめています。色を変えたくなったら、ここを 1 か所直せばサイト全体が変わります。

base.css(tokens の抜粋)
@layer tokens {
  :root {
    /* 葉の緑。数字が大きいほど濃い */
    --leaf-800: #284831;
    --leaf-600: #416b49;
    …
    /* 花びらの黄色 */
    --sun-500: #e2b65e;
    …
    /* 役割で名前を付けた色。ページではこちらを使う。
       暗い背景の .dark では、この値だけを差し替える(components.css) */
    --text: #2d3b31;
    --heading: var(--leaf-800);
    --surface: var(--white);          /* カードの地 */
    --icon-bg: var(--leaf-600);
    …
    --radius: 18px;
    --shadow: 0 12px 32px -14px rgb(30 56 39 / .3);   /* 横 縦 ぼかし 広がり 色 */
  }
}

変数は 2 段に分けています。--leaf-600 のような色そのものの名前と、--text・--heading・--surface のような役割の名前です。部品の CSS は役割の名前だけを使います。

この分け方が生きるのが、暗い背景の区画です。.dark は、役割の変数の値を差し替えるだけのクラスです。

components.css(.dark の抜粋)
.dark {
  --text: rgb(255 253 250 / .92);
  --text-soft: rgb(255 253 250 / .75);
  --heading: var(--white);
  --accent: var(--sun-500);
  …
  --surface: rgb(255 255 255 / .08);     /* 暗い背景の上のカードは、白をうっすら重ねる */
  --icon-bg: var(--sun-500);
  --icon-fg: var(--leaf-900);

  color: var(--text);
  background-color: var(--leaf-800);
}

カスタムプロパティは、親から子へ引き継がれます。class="section dark" を付けたセクションの中では、カードもアイコンも見出しも、差し替えた値で描かれます。部品ごとに「暗い背景用」の CSS を書き足す必要はありません。ガイドのダークモードも、同じ考え方で作っています。

06

1 つの .grid で何でも並べる

商品のカード、写真、料金表とシミュレーター、フッターの 3 列。このサイトで横に並ぶものは、ほとんどが同じ .grid クラスで並んでいます。メディアクエリを書かなくても、画面の幅に合わせて列の数が変わります。

base.css(layout の抜粋)
.grid {
  display: grid;
  gap: clamp(1rem, 2vw, 1.5rem);
  grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--min, 15rem)), 1fr));
}

3 行目を、内側から順に読みます。

  • var(--min, 15rem) … 1 列の最小幅。--min が無ければ 15rem
  • min(100%, …) … 画面が最小幅より狭いときは、はみ出さずに画面の幅に収める
  • minmax(最小, 1fr) … 各列は最小幅以上で、余った幅は均等に分ける
  • repeat(auto-fit, …) … 入るだけの数の列を作る

使う側は、HTML で class="grid service-grid" のように 2 つのクラスを並べ、CSS では最小幅の --min だけを変えます。

sections.css(抜粋)
.service-grid { --min: 24rem; }

.reason-grid { --min: 15rem; }

.reason-feature {
  grid-column: 1 / -1;                 /* 1 列目から最後の列まで。つまり横いっぱい */
  …
}

「お花と雑貨」は最小幅を 24rem と大きめにしたので、広い画面でも 2 列 × 2 段になります。「こだわり」は先頭の 1 枚だけに grid-column: 1 / -1 を付けて、横いっぱいに広げています。

部品の「調整用の変数」

同じ考え方で、写真の枠には縦横比の --ratio、カードには内側の余白の --card-pad を用意しています。部品の CSS は共通のまま、使う場所で変数の値だけを変えます。

07

一列に並べるなら Flexbox を使う

行と列をそろえるカードの一覧は Grid、ボタンの中の文字とアイコンや、長さの違うタグのように、中身の大きさのまま一列に並べるものは Flexbox で並べています。

components.css(タグの抜粋)
.tag-list {
  display: flex;
  flex-wrap: wrap;
  gap: .4rem;
  margin-block-start: 1rem;
  …
}

「一輪から」「季節の花」のような札は、文字の数で幅が変わります。flex-wrap: wrap を付けると、入りきらない札は次の行へ送られます。

店主のご挨拶では、写真と文章を Flexbox で横に並べ、flex で伸び縮みの具合を決めています。

sections.css(店主のご挨拶の抜粋)
.profile {
  --card-pad: clamp(1.5rem, 4vw, 3rem);
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: clamp(1.5rem, 4vw, 3rem);

  /* flex: 0 1 260px … 広がらない・縮んでよい・基本の幅は 260px */
  .profile-photo {
    …
    flex: 0 1 260px;
    …
  }

  /* flex: 1 1 22rem … 余った幅まで広がる・縮んでよい・基本の幅は 22rem */
  .profile-body { flex: 1 1 22rem; }
  …
}

flex には「伸びるか・縮むか・基本の幅」の 3 つの値を書きます。写真は 260px のまま、文章は余った幅いっぱいに広がります。幅が足りなくなると文章が次の行へ回り、写真と文章が縦に並びます。

料金シミュレーターの「合計金額」の行は、justify-content: space-between で、項目名を左端に、金額を右端に置いています。

08

写真の枠をそろえ、重ねて飾る

写真は縦長・横長が混ざっていても、枠の形をそろえて並べています。写真の上に文字やカードを重ねる工夫も、いくつか使っています。

components.css(写真の枠)
.photo {
  overflow: hidden;                  /* 角丸の外にはみ出した写真を隠す */
  border-radius: var(--radius-l);
  box-shadow: var(--shadow);

  img {
    inline-size: 100%;
    aspect-ratio: var(--ratio, 4 / 3);   /* --ratio が無ければ 4 / 3 */
    object-fit: cover;
  }
}

aspect-ratio で枠の縦横比を決め、object-fit: cover で写真を枠いっぱいに広げます。はみ出した部分は切り取られます。ヒーローの写真は --ratio: 16 / 10、季節の花たちは --ratio: 3 / 4 のように、使う場所で縦横比だけを変えています。

写真の上に文字を載せる

「こだわり」の先頭のカードは、写真を背景に敷き、その上に暗いグラデーションを 1 枚重ねています。写真のどこに文字が来ても読めるようにするためです。

sections.css(抜粋)
.reason-feature {
  …
  /* 写真の上に暗いグラデーションを 1 枚重ね、文字を読みやすくする
     カンマで区切った背景は、先に書いたものが手前に重なる */
  background-image:
    linear-gradient(90deg, rgb(30 56 39 / .92) 35%, rgb(30 56 39 / .35)),
    url("../images/image.webp");
  background-size: cover;              /* 写真を、枠いっぱいに広げる */
  background-position: center 58%;     /* 写真のどこを見せるか(横 中央・縦 58%) */
  …
}

カードを傾けて重ねる

ヒーローの紹介カード(01 章の画面の右下)は、上の余白をマイナスにして写真に食い込ませ、rotate で少し傾けています。マウスを乗せると、まっすぐに戻ります。

sections.css(抜粋)
.hero-card {
  position: relative;                 /* 写真より手前に重ねる */
  inline-size: min(80%, 300px);
  margin-block-start: -3rem;          /* 上へ食い込ませて、写真に重ねる */
  margin-inline: auto 1.5rem;         /* 右寄せ */
  text-align: center;
  rotate: -2deg;                      /* マイナスで反時計回りに傾く */
  transition: rotate .4s ease;

  &:hover { rotate: 0deg; }
  …
}

見出しの「ひだまり」の下の黄色い線も、グラデーションで描いています。linear-gradient(transparent 62%, rgb(226 182 94 / .4) 62%) は、上から 62% までが透明、そこから下が黄色という背景です。同じ位置で色を切り替えると、ぼかしのない線になります。

10

スクロールに合わせて表示を変える

ヘッダーの下端の線は、ページをどこまで読んだかに合わせて伸びます。各セクションは、画面に入ったときにふわっと現れます。どちらも、JavaScript は値やクラスを渡すだけで、見た目は CSS が決めています。

読み進めた割合を線で示す

JavaScript が割合を計算し、カスタムプロパティ --progress として要素に渡します。CSS は、その値を線の幅に使います。

main.js(抜粋)
// スクロールできる長さ = ページ全体の高さ − 画面の高さ
const scrollableHeight = document.documentElement.scrollHeight - window.innerHeight;
// 「条件 ? A : B」は、条件が正しければ A、そうでなければ B になる書き方
const progress = scrollableHeight > 0 ? Math.min(scrollPosition / scrollableHeight, 1) : 0;
scrollProgress.style.setProperty('--progress', (progress * 100).toFixed(1) + '%');
components.css(抜粋)
.scroll-progress {
  position: absolute;
  inset-block-end: -1px;
  inset-inline-start: 0;
  inline-size: var(--progress, 0%);
  block-size: 3px;
  background-image: linear-gradient(90deg, var(--leaf-600), var(--sun-500));   /* 左から右へ、緑から黄色へ */
}

scroll イベントは 1 秒に何十回も起きます。main.js では、そのたびに計算せず、requestAnimationFrame で次の描画の直前に 1 回だけ計算するようにしています。

画面に入ったら表示する

HTML で data-reveal を付けた要素が対象です。IntersectionObserver は、要素が画面に入ったかどうかを検出する仕組みです。入ったら .is-revealed を付け、その要素の監視をやめます。

utilities.css(抜粋)
[data-reveal] {
  opacity: 0;
  translate: 0 24px;
  transition: opacity .7s ease, translate .7s ease;

  &.is-revealed {
    opacity: 1;
    translate: 0 0;
  }
}

/* JavaScript が動かないときも、内容は必ず見えるようにする
   <html class="no-js"> の no-js は、JavaScript が動くと main.js が取り除く。
   残っているのは JavaScript が動かなかったときなので、最初から表示しておく。 */
.no-js [data-reveal] {
  opacity: 1;
  translate: 0 0;
}

最初に透明にしておく演出は、JavaScript が動かないと内容が見えないままになります。そこで <html class="no-js"> と書いておき、main.js の先頭でこのクラスを外しています。no-js が残っているのは JavaScript が動かなかったときなので、最初から表示します。

動きを減らす設定の人には動かさない

OS で「視差効果を減らす」「動きを減らす」を選んでいる人には、prefers-reduced-motion で動きを止めます。変化の時間をほぼ 0 にすると、1 回で終わる動きは一瞬で終わります。

ただし、ヒーローの上を流れ続けるキャッチコピー(infinite のアニメーション)は、時間を短くすると高速で回り続けてしまいます。そのため、この要素だけは animation: none で止めています。

utilities.css(抜粋)
@media (prefers-reduced-motion: reduce) {
  …
  /* すべての要素のアニメーションと変化の時間を、ほぼ 0 にする */
  *, *::before, *::after {
    animation-duration: .01ms;
    transition-duration: .01ms;
  }
  …
  /* 流れ続ける動きは、時間を短くすると高速で回ってしまうので、止める */
  .marquee p { animation: none; }
}

11

選んだ内容から料金を計算する

サイズ・点数・オプションを選ぶと、合計金額がその場で変わります。入力部品は HTML の標準のものを使い、選ばれている行の強調は CSS の :has()、計算は JavaScript が受け持っています。

料金の目安のセクション。左に S・M・L の価格表、右に暗い緑のカードで、サイズのラジオボタン、点数のスライダー、オプションのチェックボックスと、合計金額 5,300 円が並ぶ。
左が料金表、右がシミュレーター。選ばれている行は黄色い枠で強調されます。
index.html(シミュレーターの抜粋)
<fieldset class="sim-field">
  <legend>サイズを選ぶ</legend>
  <label class="choice"><input type="radio" name="size" value="3000">S(3,000円)</label>
  <label class="choice"><input type="radio" name="size" value="5000" checked>M(5,000円)</label>
  <label class="choice"><input type="radio" name="size" value="8000">L(8,000円)</label>
</fieldset>
<div class="sim-field">
  <label for="simQuantity">点数:<output id="simQuantityOut" for="simQuantity">1 点</output></label>
  <input type="range" id="simQuantity" min="1" max="5" step="1" value="1">
</div>
…
<p class="sim-result">合計金額<output class="sim-amount" id="simAmount" aria-live="polite">5,300 円</output></p>
  • <fieldset> と <legend> で、関係のある選択肢をまとめ、見出しを付けます。
  • <label> で入力部品を包むと、文字を押しても選べます。
  • 金額は value に数字だけで書いておき、JavaScript が読み取ります。
  • <output> は計算結果を表示する要素です。aria-live="polite" を付けたので、値が変わると読み上げソフトが読みます。

選ばれている行の強調には、JavaScript を使っていません。:has(input:checked) は「チェックされた input を中に持つ要素」を選ぶセレクタです。

sections.css(抜粋)
.choice {
  …
  input {
    …
    accent-color: var(--sun-500);
  }

  &:has(input:checked) {
    border-color: var(--sun-500);
    background-color: rgb(226 182 94 / .15);
  }
}

accent-color は、ラジオボタンやチェックボックス、スライダーの色を変えるプロパティです。

計算は「点数 ×(サイズの価格 + 選んだオプションの価格の合計)」です。

main.js(抜粋)
const yen = new Intl.NumberFormat('ja-JP');   // 5300 を「5,300」のように 3 桁ごとに区切る書式

function updateEstimate() {
  // 入力欄の値は文字列なので、Number() で数に直してから計算する
  const quantity = Number(quantityInput.value);
  const selectedSize = sizeInputs.find((input) => input.checked);   // 選ばれているラジオボタン
  const basePrice = Number(selectedSize.value);

  // チェックされたオプションだけを取り出し(filter)、価格を足し合わせる(reduce)。0 は足し始めの値
  const optionPrice = optionInputs
    .filter((input) => input.checked)
    .reduce((sum, input) => sum + Number(input.value), 0);
  const total = quantity * (basePrice + optionPrice);

  quantityOutput.textContent = quantity + ' 点';
  amountOutput.textContent = yen.format(total) + ' 円';
}

// スライダーは動かしている最中(input)、ラジオとチェックは選んだとき(change)に計算し直す
quantityInput.addEventListener('input', updateEstimate);

オプションは、HTML の name="option" を持つチェックボックスをすべて集めて計算しています。HTML にチェックボックスを 1 行足せば、JavaScript を直さなくても計算に加わります(13 章)。

13

自分で改造してみる

読むだけでなく、少しずつ書き換えてみると、仕組みがよく分かります。始める前に hidamari-flower フォルダーを丸ごとコピーしておくと、いつでも元に戻せます。

  1. サイトの色を変える

    base.css の --leaf-600 を別の色にして、再読み込みします。ボタン、アイコン、表の見出しがまとめて変わります(05 章)。

  2. 列の数を変える

    sections.css の .service-grid { --min: 24rem; } を 15rem にすると、広い画面で 4 枚が横一列に並びます(06 章)。

  3. よくある質問を足す

    index.html の <details> を 1 行コピーして、質問と答えを書き換えます(03 章)。

  4. 料金のオプションを足す

    「花器」のチェックボックスを 1 行コピーし、文字と value の金額を書き換えます。JavaScript を直さなくても、合計に加わります(11 章)。

  5. 写真を 1 枚足す

    季節の花たちの <figure> を 1 つコピーし、画像のファイル名と説明を書き換えます。拡大表示の枚数も自動で増えます(12 章)。

14

次の一歩

このサイトは、ガイドの HTML・CSS・JavaScript の範囲だけで作っています。気になった書き方があれば、各章の「ガイド」のリンクから、該当する節を読み直してください。反対に、ガイドの節にある「作例」のリンクから、このページへ戻ってくることもできます。

作ってみよう

身近なお店や、自分の好きなことを紹介する 1 ページのサイトを作ってみましょう。このサイトの base.css と components.css を土台に借りて、色の変数とセクションを書き換えるところから始めると、早く形になります。

作例 / ウェブアプリじゃんけんゲームを育てて読むシンプル版から本格版へ。状態の管理、成績の保存、ファイルの読み書き、PWA が加わる様子を見ます。

作ったサイトを公開するなら、公開編へ進むこともできます。