Service Page Band Composition
A service section that composes a sequence of bands instead of rearranging items inside a single section. One 12-column hairline grid runs through every band; each band only changes how that grid is divided and how tall it is, so the page moves through a cadence of densities: one wide column, a thin 12-cell strip, full-width rows split 1/4/4/3, two columns, then back to one. Every band header prints its own column and item count, making the composition itself readable. The bands are linked: picking a topic in the strip leaves only the matching service rows (hover previews, click pins). Cell borders come from grid gaps so they line up with the grid behind them. No uppercase English kicker, no trailing arrow links, no rounded corners, no scroll-reveal. Zero images, zero dependencies.
- Added:
- 2026-09-21
- Dependencies:
- None
- tags
- #block #service #page-composition #bands #density #column-grid #cross-section-filter #container-query #hairline #cardless #a11y #no-image #no-deps
Preview
サービスの考え方
作って終わりにせず、公開してからの数年を一緒に見るために、範囲と手順を先に決めます
- 引き受ける範囲
- 設計・デザイン・実装・公開後の運用
- 進め方
- 2週ごとに動くものを見ながら決める
- 引き継ぎ
- 操作手順と更新できる箇所の一覧を添える
取り扱う領域
提供サービス
- 01
会社案内サイトの制作
載せる順番を決めるところから引き受けます。公開後にご自身で直せる範囲を先に合意してから作ります。
- 規模の目安
- 8〜16ページ
- 期間
- 8〜12週
- 02
申込ページの制作
1枚で読み切れる構成にし、申込までの導線と計測をまとめて用意します。文章の草案もこちらで書きます。
- 規模の目安
- 1ページ
- 期間
- 4〜6週
- 03
公開後の更新・改善
数字を見ながら毎月すこしずつ直します。対応する時間の上限を決めてから始めるので、費用が膨らみません。
- 規模の目安
- 月4〜8時間
- 期間
- 継続
- 04
業務用の画面開発
手作業で回している集計や台帳を、社内だけで使う小さな画面に置き換えます。既存の道具は残したまま繋ぎます。
- 規模の目安
- 2〜6画面
- 期間
- 6〜10週
はじめに決めること
見積りの幅は、作業量よりも決まっていない事柄の数で広がります。最初の打ち合わせでこの3つの見当がつけば、金額も日程も、幅ではなく1つの数で出せます
- 公開したい時期
- 逆算して、決めていただく期限をこちらから出します
- 更新する人
- 誰が何を直すかで、作り方も引き継ぐ資料も変わります
- 用意できる素材
- 写真と文章の当てがない箇所は、代わりの手を先に決めます
相談の入口
まだ何も決まっていない段階でかまいません。決め方のほうからお話しします
相談してみる"use client";
import { useState } from "react";
import { Montserrat } from "next/font/google";
const montserrat = Montserrat({
weight: ["500", "600", "700"],
subsets: ["latin"],
display: "swap",
});
/**
* サービス案内の帯構成(1節ではなく節の連なりを組む)
* 既存のサービス系は service-zigzag / service-photo-stack / strength-bento-grid / six-faces-cube の
* ように「1つの節の中で N 件をどう並べるか」を解いたもので、節と節のあいだの設計を持つものは無い。
* 本ブロックは扱う単位を節の連なりへ上げ、1枚の12列方眼を全帯に貫通させたうえで、帯ごとに
* その方眼の割り方(1列・12列・12列割りの行・2列・1列の順)と高さを変え、密度の起伏を作る。
* 各帯の見出しは右端に「その帯が何列・何項目か」を出し、構成そのものを読める情報にしている。
* 帯は独立していない——領域の帯で語を選ぶと、サービスの帯の該当行だけが残る(節をまたぐ絞り込み)。
* 飽和idiomは英字キッカーと末尾の矢印リンクを外し、角丸・スクロール出現アニメもゼロ。
* 掲載文はすべてオリジナル。画像素材ゼロ・依存ゼロ。
*
* 汎用技法メモ(web-design-playbook 還流用):
* - **方眼は1枚だけ置き、帯は割り方だけを変える**: 罫線を帯ごとに引くと列がズレて「同じページ」に
* 見えない。`linear-gradient(to right, line 0 1px, transparent 1px)` を root に absolute で
* 1枚敷き、`background-repeat: repeat-x` + `background-size: calc(100%/12) 100%` で列幅を
* 決める。狭幅は `[background-size:25%_100%]` で4列に落とす(列数の変更が1箇所で済む)。
* ここを `repeating-linear-gradient` で書くと**周期が自分の最終色停止位置=1pxになり**、
* background-size が効かずに面全体が薄いグレーで塗り潰される(線が1本も出ない)。
* タイルの幅を background-size で決めるなら、勾配は必ず非repeating側で書く。
* - **セルの境界は gap で作る**: `grid gap-px bg-gray-200` + セルを `bg-white` にすると、
* セル数がいくつでも罫線が二重にならず、背面の方眼と同じ位置に線が落ちる(border では
* 隣接セルで2px になり方眼とズレる)。
* - **密度は高さで語る**: 低密度の帯は列を使わず縦padding を厚く、高密度の帯は列いっぱいに
* 割って高さを薄くする。同じ幅の中で「1列で厚い / 12列で薄い」を交互に置くと、
* カードを使わずにページのリズムが立つ。
* - **節をまたぐ絞り込みは preview と pinned の2状態で持つ**: hover/focus は preview、click は
* pinned(`aria-pressed`)。実効値は `preview ?? pinned` なので、ポインタでの試し読みと
* キーボードでの固定が衝突しない。減衰は opacity のみ・帯の見出しの読み値は aria-live で伝える。
*/
type Domain = { label: string; services: number[] };
/** 帯01: 取り扱い領域(12列の方眼を1列ずつ使い切る薄い帯) */
const domains: Domain[] = [
{ label: "会社案内", services: [0] },
{ label: "採用情報", services: [0] },
{ label: "商品紹介", services: [0, 1] },
{ label: "予約・申込", services: [1] },
{ label: "資料請求", services: [1] },
{ label: "多言語", services: [0] },
{ label: "記事更新", services: [2] },
{ label: "写真撮影", services: [0, 1] },
{ label: "計測設定", services: [1, 2] },
{ label: "表示速度", services: [2] },
{ label: "問い合わせ", services: [1, 2] },
{ label: "社内画面", services: [3] },
];
/** 帯02: 提供サービス(方眼を 1 / 4 / 4 / 3 に割った全幅の行) */
const services = [
{
no: "01",
name: "会社案内サイトの制作",
body: "載せる順番を決めるところから引き受けます。公開後にご自身で直せる範囲を先に合意してから作ります。",
scale: "8〜16ページ",
span: "8〜12週",
},
{
no: "02",
name: "申込ページの制作",
body: "1枚で読み切れる構成にし、申込までの導線と計測をまとめて用意します。文章の草案もこちらで書きます。",
scale: "1ページ",
span: "4〜6週",
},
{
no: "03",
name: "公開後の更新・改善",
body: "数字を見ながら毎月すこしずつ直します。対応する時間の上限を決めてから始めるので、費用が膨らみません。",
scale: "月4〜8時間",
span: "継続",
},
{
no: "04",
name: "業務用の画面開発",
body: "手作業で回している集計や台帳を、社内だけで使う小さな画面に置き換えます。既存の道具は残したまま繋ぎます。",
scale: "2〜6画面",
span: "6〜10週",
},
];
/** 帯03: はじめに決めること(2列の帯) */
const decisions = [
{ term: "公開したい時期", note: "逆算して、決めていただく期限をこちらから出します" },
{ term: "更新する人", note: "誰が何を直すかで、作り方も引き継ぐ資料も変わります" },
{ term: "用意できる素材", note: "写真と文章の当てがない箇所は、代わりの手を先に決めます" },
];
/** 帯の見出し(左=通し番号 / 中=和文見出し / 右=その帯の割り方) */
function BandHead({
n,
title,
readout,
live,
headingTag: Tag = "h3",
id,
}: {
n: string;
title: string;
readout: string;
live?: boolean;
headingTag?: "h2" | "h3";
id?: string;
}) {
return (
<div className="flex items-end justify-between gap-4 border-b border-gray-200 px-5 pb-3 @2xl:px-8">
<div className="flex items-end gap-3 @2xl:gap-5">
<span
className={`${montserrat.className} shrink-0 pb-[3px] text-[11px] leading-none font-semibold tracking-widest text-amber-600 tabular-nums @2xl:text-[13px]`}
>
{n}
</span>
<Tag
id={id}
className="text-[1.0625rem] leading-[1.35] font-bold tracking-tight text-gray-900 @2xl:text-[1.375rem]"
>
{title}
</Tag>
</div>
<span
aria-live={live ? "polite" : undefined}
className="shrink-0 pb-[3px] text-right text-[10px] leading-[1.5] text-gray-500 tabular-nums @2xl:text-[11px]"
>
{readout}
</span>
</div>
);
}
export default function ServiceBandComposition() {
// hover/focus は preview、click は pinned。実効値は preview ?? pinned。
const [pinned, setPinned] = useState<number | null>(null);
const [preview, setPreview] = useState<number | null>(null);
const active = preview ?? pinned;
const activeDomain = active === null ? null : domains[active];
return (
<section
aria-labelledby="svc-band-heading"
className={`@container relative w-full max-w-5xl border-y border-gray-200 bg-white`}
>
{/* 全帯を貫く12列の方眼(狭幅は4列)。帯はこの割り方を変えるだけで、線そのものは動かさない */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 [background-image:linear-gradient(to_right,rgba(34,30,25,0.1)_0_1px,transparent_1px)] [background-repeat:repeat-x] [background-size:25%_100%] @2xl:[background-size:calc(100%/12)_100%]"
/>
{/* 帯00: 1列・余白を最大に取る導入 */}
<div className="relative pt-8 @2xl:pt-12">
<BandHead
n="00"
title="サービスの考え方"
readout="1列・全幅"
headingTag="h2"
id="svc-band-heading"
/>
<div className="px-5 py-10 @2xl:px-8 @2xl:py-16">
<p className="max-w-[24ch] text-[1.25rem] leading-[1.75] font-bold tracking-tight text-gray-900 text-balance @2xl:text-[1.75rem] @2xl:leading-[1.7]">
作って終わりにせず、公開してからの数年を一緒に見るために、範囲と手順を先に決めます
</p>
<dl className="mt-8 grid max-w-2xl grid-cols-1 gap-px bg-gray-200 @md:grid-cols-3 @2xl:mt-12">
{[
{ t: "引き受ける範囲", d: "設計・デザイン・実装・公開後の運用" },
{ t: "進め方", d: "2週ごとに動くものを見ながら決める" },
{ t: "引き継ぎ", d: "操作手順と更新できる箇所の一覧を添える" },
].map((s) => (
<div key={s.t} className="bg-white py-4 pr-4 @md:px-4 @md:first:pl-0">
<dt className="text-[11px] font-bold tracking-wide text-amber-700">{s.t}</dt>
<dd className="mt-1.5 text-[13px] leading-[1.7] text-gray-600">{s.d}</dd>
</div>
))}
</dl>
</div>
</div>
{/* 帯01: 12列を1列ずつ使い切る薄い帯。押すとサービスの帯が絞り込まれる */}
<div className="relative border-t border-gray-200 pt-6 @2xl:pt-8">
<BandHead n="01" title="取り扱う領域" readout="12列・12項目" />
<ul className="grid grid-cols-4 gap-px bg-gray-200 @2xl:grid-cols-12">
{domains.map((d, i) => {
const on = active === i;
return (
<li key={d.label} className="bg-white">
<button
type="button"
aria-pressed={pinned === i}
onMouseEnter={() => setPreview(i)}
onMouseLeave={() => setPreview(null)}
onFocus={() => setPreview(i)}
onBlur={() => setPreview(null)}
onClick={() => setPinned((p) => (p === i ? null : i))}
className={`relative flex h-full w-full items-start px-2.5 pt-4 pb-5 text-left text-[11px] leading-[1.5] font-medium transition-colors duration-200 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none ${
on ? "bg-amber-50 text-amber-800" : "text-gray-700 hover:bg-gray-50"
}`}
>
<span
aria-hidden="true"
className={`absolute inset-x-0 top-0 h-[2px] origin-left bg-amber-600 transition-transform duration-200 ease-out motion-reduce:transition-none ${
on ? "scale-x-100" : "scale-x-0"
}`}
/>
{d.label}
</button>
</li>
);
})}
</ul>
</div>
{/* 帯02: 方眼を 1/4/5/2 に割った全幅の行。帯01の選択でここだけが絞り込まれる */}
<div className="relative border-t border-gray-200 pt-6 @2xl:pt-8">
<BandHead
n="02"
title="提供サービス"
live
readout={
activeDomain
? `${activeDomain.label}に関わる ${activeDomain.services.length}行`
: "4行・12列割り"
}
/>
<ul>
{services.map((s, i) => {
const dimmed = activeDomain !== null && !activeDomain.services.includes(i);
const marked = activeDomain !== null && activeDomain.services.includes(i);
return (
<li
key={s.no}
className={`relative border-b border-gray-200 transition-opacity duration-300 last:border-b-0 motion-reduce:transition-none ${
dimmed ? "opacity-40" : "opacity-100"
}`}
>
<span
aria-hidden="true"
className={`absolute top-0 bottom-0 left-0 w-[3px] origin-top bg-amber-600 transition-transform duration-300 ease-out motion-reduce:transition-none ${
marked ? "scale-y-100" : "scale-y-0"
}`}
/>
<div className="grid grid-cols-1 gap-x-5 gap-y-2 px-5 py-6 @2xl:grid-cols-12 @2xl:px-8 @2xl:py-7">
<span
className={`${montserrat.className} text-[11px] leading-none font-semibold tracking-widest text-gray-400 tabular-nums @2xl:col-span-1 @2xl:pt-[5px]`}
>
{s.no}
</span>
<h4 className="text-[1rem] leading-[1.45] font-bold tracking-tight text-gray-900 @2xl:col-span-4 @2xl:text-[1.0625rem]">
{s.name}
</h4>
<p className="text-[13px] leading-[1.8] text-gray-600 @2xl:col-span-4 @2xl:text-[13.5px]">
{s.body}
</p>
<dl className="flex gap-5 text-[11px] leading-[1.6] @2xl:col-span-3 @2xl:flex-col @2xl:gap-2 @2xl:text-right">
<div>
<dt className="text-gray-400">規模の目安</dt>
<dd className="font-medium text-gray-700 tabular-nums">{s.scale}</dd>
</div>
<div>
<dt className="text-gray-400">期間</dt>
<dd className="font-medium text-gray-700 tabular-nums">{s.span}</dd>
</div>
</dl>
</div>
</li>
);
})}
</ul>
</div>
{/* 帯03: 2列。本文と項目を左右に分けて密度を落とす */}
<div className="relative border-t border-gray-200 pt-6 @2xl:pt-8">
<BandHead n="03" title="はじめに決めること" readout="2列・3項目" />
<div className="grid grid-cols-1 gap-8 px-5 py-8 @2xl:grid-cols-12 @2xl:gap-10 @2xl:px-8 @2xl:py-10">
<p className="max-w-[34ch] text-[13.5px] leading-[1.95] text-gray-700 @2xl:col-span-5">
見積りの幅は、作業量よりも決まっていない事柄の数で広がります。最初の打ち合わせでこの3つの見当がつけば、金額も日程も、幅ではなく1つの数で出せます
</p>
<dl className="@2xl:col-span-7">
{decisions.map((d) => (
<div
key={d.term}
className="grid grid-cols-1 gap-1 border-t border-gray-200 py-3.5 first:border-t-0 first:pt-0 @md:grid-cols-[10rem_1fr] @md:gap-5"
>
<dt className="text-[13px] leading-[1.6] font-bold text-gray-900">{d.term}</dt>
<dd className="text-[12.5px] leading-[1.75] text-gray-600">{d.note}</dd>
</div>
))}
</dl>
</div>
</div>
{/* 帯04: 1列に戻して終端。角丸ゼロ・矢印なしのテキストボタン */}
<div className="relative border-t border-gray-200 pt-6 @2xl:pt-8">
<BandHead n="04" title="相談の入口" readout="1列・終端" />
<div className="flex flex-col gap-6 px-5 py-10 @2xl:flex-row @2xl:items-end @2xl:justify-between @2xl:px-8 @2xl:py-14">
<p className="max-w-[28ch] text-[1rem] leading-[1.8] font-bold tracking-tight text-gray-900 text-balance @2xl:text-[1.1875rem]">
まだ何も決まっていない段階でかまいません。決め方のほうからお話しします
</p>
<a
href="#contact"
className="inline-flex shrink-0 items-center justify-center border-2 border-gray-900 px-7 py-3.5 text-[13px] font-bold tracking-wide text-gray-900 transition-colors duration-200 hover:bg-gray-900 hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
>
相談してみる
</a>
</div>
</div>
</section>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/service-band-composition.json