BLK-25Blocks
History Timeline (Hairline)
A company-history timeline drawn with a single hairline rail and year nodes. An IntersectionObserver reveals each entry once, in order: the line draws down, a thin concentric ring node appears, then the year and copy rise into place. Grouped with hairlines and negative space instead of cards — no images, no dependencies. Years and events are fictional placeholders.
- Added:
- 2026-08-14
- Dependencies:
- None
- tags
- #history #timeline #milestone #hairline #intersection-observer #reveal #motion #no-image
Preview
BLK-25 — History
一年ずつ、線を引いてきました。
出来事を並べるだけでは、どこへ向かっているのかは伝わりません。一本の線の上に置き直すと、選んできたことの順番が見えてきます。
- 設立
机ひとつから始める
ふたりでひとつの机を囲むところから始めました。受けられる数より、続けられる質のほうを先に決めています。
- 体制
通しで見る、を守る
分業で速度を上げる代わりに、設計から実装までをひとりが通しで受け持つ進め方を選びました。
- 領域
公開した後を、引き受ける
つくって終わりにせず、公開後の数値を見ながら直していく月次の伴走を、標準の選択肢に加えました。
- 蓄積
手順を、道具に変える
現場で繰り返していた確認作業を切り出し、誰でも使えるかたちに整えて社外へ公開しました。
- 現在
線は、まだ伸びている
積み上げた型を、再現できるかたちで配ること。それが次の十年に向けた、いまの起点です。
"use client";
import { useEffect, useRef } from "react";
import { Montserrat } from "next/font/google";
const montserrat = Montserrat({
weight: ["500", "600", "700"],
subsets: ["latin"],
display: "swap",
});
/**
* 沿革・ヒストリータイムライン(細線ドローイング) / History — Hairline Timeline
* 参考: 「縦の細線+年号ノードで時間を刻む」というレイアウト構造の着想のみを取り込み、
* 配色・文言・構成は First CH ブランド(白基調+アンバー #d97706)で全面的に再構築している。
* 年号・出来事はすべて架空のプレースホルダー。画像素材ゼロ・依存ゼロ。
*
* 汎用技法メモ(web-design-playbook 還流用):
* - **線を「引く」のは scaleY、面を作らない**: 1件=1本の hairline セグメント(absolute な w-px)を
* 縦に連ねると、隣接セグメントが継ぎ目なく1本の線に見える。各セグメントを origin-top の
* scaleY(0)→1 で伸ばせば、上から下へ線が引かれていく。全体を1本の長い線にして伸ばすより、
* **セグメント分割のほうが「読み進めた分だけ引かれる」に合致し、absolute の位置計算も要らない**。
* - **リビールは1件ずつ・1度だけ**: IntersectionObserver を1つ作り、各 <li> を observe する。
* 交差したら `data-shown` を付けて即 `unobserve`。再入場でやり直さないので、行き来しても
* 線が引き直されない。状態は DOM 属性に置き setState しない(項目が増えても再描画コストゼロ)。
* - **スタッガーはCSS側に閉じる**: `[data-shown]` 配下の子孫に transition-delay を書き分けるだけで、
* 線 → ノード → 年号 → 本文 の順に立ち上がる。JS は属性を1つ付けるだけで済む。
* - **ノードは同心円のドローイング**: 細い border だけのリング+中心の小さな塗り。現在地だけ
* 外周にもう1本リングを重ねると、塗り面積を増やさずに「いまここ」を強調できる。リングに
* bg-white を敷くと背後の線が中心で切れ、線と円が接続して見える。
* - **絶対配置の水平センタリング**: `absolute inset-x-0 mx-auto w-px` で幅の狭い要素を親の中央へ。
* `left-1/2` + translate を使わないので、`transform` をアニメーション用に丸ごと空けておける
* (Tailwind の translate ユーティリティと自前の transform を1要素で混ぜない)。
* - reduced-motion: JS 側は observe せず全件に `data-shown` を付け、CSS 側も transition/transform を
* 打ち消して**完成状態を唯一の状態**にする(「止める」ではなく「最初から引き終わっている」)。
*/
type Milestone = {
/** 年(time要素の datetime にもそのまま使う) */
year: string;
/** 年の性格を示す和文ミニラベル */
kicker: string;
title: string;
body: string;
/** 現在地。ノードに同心円をもう1本重ねる */
current?: boolean;
};
const milestones: Milestone[] = [
{
year: "2018",
kicker: "設立",
title: "机ひとつから始める",
body: "ふたりでひとつの机を囲むところから始めました。受けられる数より、続けられる質のほうを先に決めています。",
},
{
year: "2020",
kicker: "体制",
title: "通しで見る、を守る",
body: "分業で速度を上げる代わりに、設計から実装までをひとりが通しで受け持つ進め方を選びました。",
},
{
year: "2022",
kicker: "領域",
title: "公開した後を、引き受ける",
body: "つくって終わりにせず、公開後の数値を見ながら直していく月次の伴走を、標準の選択肢に加えました。",
},
{
year: "2024",
kicker: "蓄積",
title: "手順を、道具に変える",
body: "現場で繰り返していた確認作業を切り出し、誰でも使えるかたちに整えて社外へ公開しました。",
},
{
year: "2026",
kicker: "現在",
title: "線は、まだ伸びている",
body: "積み上げた型を、再現できるかたちで配ること。それが次の十年に向けた、いまの起点です。",
current: true,
},
];
export default function HistoryTimeline() {
const rootRef = useRef<HTMLElement>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const items = Array.from(
root.querySelectorAll<HTMLElement>("[data-blk25-item]")
);
const show = (el: HTMLElement) => el.setAttribute("data-shown", "");
// reduced-motion / IO 非対応: 監視せず完成状態で置く
if (
typeof IntersectionObserver === "undefined" ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
items.forEach(show);
return;
}
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
show(entry.target as HTMLElement);
io.unobserve(entry.target); // 一度だけ
}
},
{ rootMargin: "0px 0px -8% 0px", threshold: 0.2 }
);
items.forEach((el) => io.observe(el));
return () => io.disconnect();
}, []);
return (
<section
ref={rootRef}
aria-labelledby="blk25-heading"
className="blk25 w-full max-w-4xl bg-white px-1"
>
<header className="max-w-xl">
<p
className={`${montserrat.className} text-[11px] font-bold tracking-[0.25em] text-amber-600 uppercase`}
>
BLK-25 — History
</p>
<h2
id="blk25-heading"
className="mt-3 text-[clamp(1.35rem,4.2vw,2rem)] leading-[1.5] font-bold tracking-tight text-gray-900"
>
一年ずつ、線を引いてきました。
</h2>
<p className="mt-4 text-[13.5px] leading-[2] font-medium text-gray-600">
出来事を並べるだけでは、どこへ向かっているのかは伝わりません。一本の線の上に置き直すと、選んできたことの順番が見えてきます。
</p>
</header>
<ol className="mt-10 border-t border-gray-200 pt-10 sm:mt-12 sm:pt-12">
{milestones.map((m, i) => {
const last = i === milestones.length - 1;
return (
<li
key={m.year}
data-blk25-item=""
className="grid grid-cols-[1.5rem_1fr] grid-rows-[auto_1fr] sm:grid-cols-[5rem_1.5rem_1fr]"
>
{/* レール+ノード(純装飾。年号は本文側の time が持つ) */}
<div
aria-hidden="true"
className="relative col-start-1 row-span-2 row-start-1 sm:col-start-2"
>
<span
className={`blk25-line absolute inset-x-0 top-0 mx-auto w-px bg-gray-300 ${
last ? "h-2" : "bottom-0"
}`}
/>
<span className="absolute inset-x-0 top-2 mx-auto size-3.5">
{m.current && (
<span className="blk25-halo absolute inset-0 m-auto block size-5 rounded-full border border-amber-600/25" />
)}
<span className="blk25-ring block size-3.5 rounded-full border border-amber-600/45 bg-white" />
<span className="blk25-dot absolute inset-0 m-auto block size-1.5 rounded-full bg-amber-600" />
</span>
</div>
{/* 年号セル: モバイルは本文の上、sm 以上はレールの左へ回り込む */}
<div className="col-start-2 row-start-1 flex items-baseline gap-2.5 pl-4 sm:col-start-1 sm:row-start-1 sm:block sm:pr-6 sm:pl-0 sm:text-right">
<time
dateTime={m.year}
className={`blk25-year ${montserrat.className} block text-[19px] leading-none font-bold text-gray-900 tabular-nums sm:text-[22px]`}
>
{m.year}
</time>
<span className="blk25-kicker block text-[11px] font-bold tracking-[0.18em] text-amber-700 sm:mt-2">
{m.kicker}
</span>
</div>
{/* 本文セル */}
<div
className={`col-start-2 row-start-2 pl-4 sm:col-start-3 sm:row-span-2 sm:row-start-1 sm:pl-0 ${
last ? "pb-0" : "pb-11 sm:pb-14"
}`}
>
<h3 className="blk25-title mt-2.5 text-[15px] leading-[1.7] font-bold tracking-tight text-gray-900 sm:mt-0 sm:text-base">
{m.title}
</h3>
<p className="blk25-body mt-2.5 max-w-lg text-[13px] leading-[2] text-gray-600">
{m.body}
</p>
</div>
</li>
);
})}
</ol>
<div className="mt-10 border-t border-gray-200 pt-6 sm:mt-12">
<a
href="#"
className="group inline-flex items-center gap-2 text-sm font-semibold text-gray-900 focus-visible:outline-2 focus-visible:outline-amber-600 focus-visible:outline-offset-4"
>
<span className="relative">
会社概要を見る
<span className="absolute -bottom-1 left-0 h-px w-full origin-left scale-x-100 bg-gray-900 transition-transform duration-300 ease-out group-hover:scale-x-0 motion-reduce:transition-none" />
<span className="absolute -bottom-1 left-0 h-px w-full origin-right scale-x-0 bg-amber-600 transition-transform delay-150 duration-300 ease-out group-hover:origin-left group-hover:scale-x-100 motion-reduce:transition-none" />
</span>
<span
aria-hidden="true"
className="inline-block transition-transform duration-300 ease-out group-hover:translate-x-1 motion-reduce:transition-none"
>
→
</span>
</a>
</div>
{/* 立ち上がりの順序はここに閉じる(JSは data-shown を1つ付けるだけ) */}
<style>{`
.blk25 [data-blk25-item] .blk25-line {
transform: scaleY(0);
transform-origin: top center;
transition: transform 760ms cubic-bezier(0.22, 1, 0.36, 1);
}
.blk25 [data-blk25-item] .blk25-ring,
.blk25 [data-blk25-item] .blk25-halo {
opacity: 0;
transform: scale(0.4);
transition: opacity 420ms ease-out 240ms, transform 620ms cubic-bezier(0.22, 1, 0.36, 1) 240ms;
}
.blk25 [data-blk25-item] .blk25-halo { transition-delay: 400ms; }
.blk25 [data-blk25-item] .blk25-dot {
opacity: 0;
transform: scale(0.2);
transition: opacity 380ms ease-out 340ms, transform 520ms cubic-bezier(0.22, 1, 0.36, 1) 340ms;
}
.blk25 [data-blk25-item] .blk25-year,
.blk25 [data-blk25-item] .blk25-kicker,
.blk25 [data-blk25-item] .blk25-title,
.blk25 [data-blk25-item] .blk25-body {
opacity: 0;
transform: translateY(14px);
transition: opacity 640ms ease-out var(--d, 0ms), transform 640ms cubic-bezier(0.22, 1, 0.36, 1) var(--d, 0ms);
}
.blk25 [data-blk25-item] .blk25-year { --d: 180ms; }
.blk25 [data-blk25-item] .blk25-kicker { --d: 250ms; }
.blk25 [data-blk25-item] .blk25-title { --d: 320ms; }
.blk25 [data-blk25-item] .blk25-body { --d: 400ms; }
.blk25 [data-blk25-item][data-shown] .blk25-line,
.blk25 [data-blk25-item][data-shown] .blk25-ring,
.blk25 [data-blk25-item][data-shown] .blk25-halo,
.blk25 [data-blk25-item][data-shown] .blk25-dot,
.blk25 [data-blk25-item][data-shown] .blk25-year,
.blk25 [data-blk25-item][data-shown] .blk25-kicker,
.blk25 [data-blk25-item][data-shown] .blk25-title,
.blk25 [data-blk25-item][data-shown] .blk25-body {
opacity: 1;
transform: none;
}
@media (prefers-reduced-motion: reduce) {
.blk25 [data-blk25-item] .blk25-line,
.blk25 [data-blk25-item] .blk25-ring,
.blk25 [data-blk25-item] .blk25-halo,
.blk25 [data-blk25-item] .blk25-dot,
.blk25 [data-blk25-item] .blk25-year,
.blk25 [data-blk25-item] .blk25-kicker,
.blk25 [data-blk25-item] .blk25-title,
.blk25 [data-blk25-item] .blk25-body {
opacity: 1 !important;
transform: none !important;
transition: none !important;
}
}
`}</style>
</section>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/history-timeline.json