First CH Designs
BLK-25Blocks

沿革・ヒストリータイムライン

縦1本の細線と年号ノードで沿革を刻むタイムライン。IntersectionObserver で1件ずつ「線が引かれ→同心円のノードが描かれ→年号と本文が立ち上がる」順に一度だけリビールする。カードで囲わず hairline と余白だけでグルーピングし、画像素材ゼロ・依存ゼロで組んでいる。年号・出来事は架空のプレースホルダー。

追加日:
2026-08-14
依存:
なし
tags
#history #timeline #milestone #hairline #intersection-observer #reveal #motion #no-image

プレビュー

BLK-25 — History

一年ずつ、線を引いてきました。

出来事を並べるだけでは、どこへ向かっているのかは伝わりません。一本の線の上に置き直すと、選んできたことの順番が見えてきます。

  1. 設立

    机ひとつから始める

    ふたりでひとつの机を囲むところから始めました。受けられる数より、続けられる質のほうを先に決めています。

  2. 体制

    通しで見る、を守る

    分業で速度を上げる代わりに、設計から実装までをひとりが通しで受け持つ進め方を選びました。

  3. 領域

    公開した後を、引き受ける

    つくって終わりにせず、公開後の数値を見ながら直していく月次の伴走を、標準の選択肢に加えました。

  4. 蓄積

    手順を、道具に変える

    現場で繰り返していた確認作業を切り出し、誰でも使えるかたちに整えて社外へ公開しました。

  5. 現在

    線は、まだ伸びている

    積み上げた型を、再現できるかたちで配ること。それが次の十年に向けた、いまの起点です。

"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>
  );
}

shadcn CLI でプロジェクトに追加

npx shadcn@latest add https://designs.first-ch.com/r/history-timeline.json