BLK-82Blocks
花びらが舞い落ちる季節演出(風向つきバックドロップ)
季節の挨拶ページや和風の案件に添える背景装飾。花びらは点ではなく板として持ち、面内2軸をZ→Y→Xの順に回してから画面へ射影し、その基底ベクトル2本をctx.transformへ直接渡す。面の見え方は行列式そのままなので、真横を向けば線になり、裏返れば裏面の色に切り替わる(角度から幅を縮める近似では出ない挙動)。落下は大きさから決める—大きい花びらは風を受けにくく重く落ちるので、層を手で分けなくても視差になる。横揺れの位相にx座標を混ぜるため、風に流れるほど揺れが進みループ感が消える。状態は積もりのような不可逆なものではなく、風の強さ3段と風向の反転だけ。切り替えは目標値へ指数で寄せる(フレーム時間を指数に入れるのでfpsが落ちても到達時間が変わらない)ため、風向を変えると花びらは減速してから逆流する。奥行きは2枚のcanvasで前後に分け、手前層だけを版面の上に重ねるので本文の前を花びらが横切る。スプライトは表裏×濃淡の8枚をcanvasへ同期で描いて使い回す。prefers-reduced-motionでは固定シードで散った一枚だけを描き、風ボタンは静止画の再描画として生き続ける。画面外・タブ非表示ではrAFを止める。依存ライブラリゼロ・画像素材ゼロ。
- 追加日:
- 2026-09-27
- 依存:
- なし
- tags
- #block #background #decoration #seasonal #canvas #particles #petals #wind #vertical-writing #motion #no-image #no-dependency
プレビュー
そよ標準の風
花のころ、いかがお過ごしですか
季節のたよりを、ひとひらに
季節のごあいさつに添える背景
ひらひらと、
向きを持って落ちる
一枚ずつが面の向きを持っているので、風に乗ると裏が見え、真横を向いた瞬間は線になります。 枚数を増やさなくても密度が出るのは、動きが「落ちる」だけで終わっていないからです。
季節の挨拶ページを相談する風の強さと向きは上のボタンで切り替わります
- 舞う枚数
- 0枚
- 風速
- 0px/s
- 面が立つ
- 0枚
- 手前層
- 0枚
"use client";
import { useEffect, useRef, useState } from "react";
import { Zen_Old_Mincho } from "next/font/google";
// preload: false は和文フォント必須(unicode-range で約37分割されるため、preload すると
// 全ページに数十本の <link rel="preload"> がぶら下がる)。
const zenOldMincho = Zen_Old_Mincho({
weight: ["400"],
subsets: ["latin"],
display: "swap",
preload: false,
});
/** 花びらが舞い落ちるバックドロップ(BLK-82)。 */
type WindLevel = 0 | 1 | 2;
const WINDS: {
label: string;
note: string;
speed: number; // 水平方向の風速(px/s・基準幅900px時)
fall: number; // 落下の基準速度(px/s)
turbulence: number; // 縦揺れの強さ
tumble: number; // 回転の速さ倍率
density: number; // 枚数の倍率
}[] = [
{ label: "凪", note: "ほとんど風がない", speed: 26, fall: 34, turbulence: 0.35, tumble: 0.5, density: 0.6 },
{ label: "そよ", note: "標準の風", speed: 96, fall: 46, turbulence: 0.7, tumble: 1, density: 1 },
{ label: "花嵐", note: "ひとしきり強く吹く", speed: 226, fall: 62, turbulence: 1.25, tumble: 1.8, density: 1.5 },
];
/** 花びらの表面(淡い順)。白基調に馴染むアンバー寄りの4段 */
const FACE = ["#f9e2c0", "#f3cd99", "#e8ae6b", "#d18e3f"];
/** 裏面は1段暗く。裏返った瞬間に色が変わるので回転が読める */
const BACK = ["#eed2a6", "#e3b87e", "#d29551", "#b0722a"];
/** 輪郭と主脈 */
const EDGE = "rgba(160, 94, 18, 0.55)";
const SPRITE = 96; // スプライト1枚の論理サイズ
const REF_W = 900; // 風速の基準幅(狭い枠では比例して落とす)
const MAX_PETALS = 132;
/** 固定シード乱数(reduced-motion の静止画を毎回同じ絵にする) */
function mulberry32(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** 1枚の花びら(表・裏)を論理サイズ SPRITE の canvas へ描く */
function makeSprite(fill: string, stroke: string): HTMLCanvasElement {
const dpr = Math.min(typeof window === "undefined" ? 1 : window.devicePixelRatio || 1, 2);
const c = document.createElement("canvas");
c.width = SPRITE * dpr;
c.height = SPRITE * dpr;
const g = c.getContext("2d");
if (!g) return c;
g.setTransform(dpr, 0, 0, dpr, 0, 0);
const cx = SPRITE / 2;
// しずく型:先端を細く、付け根を丸く。先端には浅い切れ込みを入れる
g.beginPath();
g.moveTo(cx, SPRITE * 0.1);
g.bezierCurveTo(SPRITE * 0.14, SPRITE * 0.3, SPRITE * 0.16, SPRITE * 0.78, cx, SPRITE * 0.92);
g.bezierCurveTo(SPRITE * 0.84, SPRITE * 0.78, SPRITE * 0.86, SPRITE * 0.3, cx, SPRITE * 0.1);
g.closePath();
g.fillStyle = fill;
g.fill();
g.strokeStyle = stroke;
g.lineWidth = 1.1;
g.stroke();
// 主脈。1本だけ入れると回転したとき面の向きが読める
g.beginPath();
g.moveTo(cx, SPRITE * 0.16);
g.lineTo(cx, SPRITE * 0.86);
g.strokeStyle = stroke;
g.lineWidth = 0.9;
g.stroke();
return c;
}
type Petal = {
x: number;
y: number;
z: number; // 0=奥 / 1=手前
size: number;
tone: number; // 濃淡(0..3)
wind: number; // 風の受けやすさ(小さい花びらほど大きい)
vx: number;
vy: number;
wave: number; // 縦揺れの位相
ax: number;
ay: number;
az: number;
sx: number; // 前後の傾きの速さ
sz: number; // 面内の回転の速さ
free: boolean; // true=水平軸まわりに回り続ける(=裏返る)少数派
sy: number; // free のときの回転速度
wob: number; // free でないときの傾きの振れ幅(rad)
fq: number; // 振れの周期(Hz)
ph: number; // 振れの位相
};
export default function PetalFallSeasonalBackdrop() {
const [wind, setWind] = useState<WindLevel>(1);
const [dir, setDir] = useState<1 | -1>(-1);
const [still, setStill] = useState(false);
const [inView, setInView] = useState(false);
const [stats, setStats] = useState({ petals: 0, speed: 0, edge: 0, near: 0 });
const stageRef = useRef<HTMLDivElement>(null);
const backRef = useRef<HTMLCanvasElement>(null);
const frontRef = useRef<HTMLCanvasElement>(null);
const windRef = useRef<WindLevel>(1);
const dirRef = useRef<1 | -1>(-1);
const apiRef = useRef<{ refresh: () => void } | null>(null);
useEffect(() => {
const id = requestAnimationFrame(() => setInView(true));
return () => cancelAnimationFrame(id);
}, []);
// 風の設定はシミュレーション側が毎フレーム読むので ref へ。静止時は1枚を描き直す
useEffect(() => {
windRef.current = wind;
dirRef.current = dir;
apiRef.current?.refresh();
}, [wind, dir]);
useEffect(() => {
const stage = stageRef.current;
const back = backRef.current;
const front = frontRef.current;
if (!stage || !back || !front) return;
const bctx = back.getContext("2d");
const fctx = front.getContext("2d");
if (!bctx || !fctx) return;
const faces = FACE.map((f) => makeSprite(f, EDGE));
const backs = BACK.map((f) => makeSprite(f, EDGE));
let w = 1;
let h = 1;
let ready = false;
let petals: Petal[] = [];
let raf = 0;
let last = 0;
let statAt = 0;
// 風は目標値へ指数で寄せる(dt を指数に入れるので fps が落ちても到達時間は同じ)
let speedNow = WINDS[1].speed;
let fallNow = WINDS[1].fall;
let turbNow = WINDS[1].turbulence;
let tumbleNow = WINDS[1].tumble;
let dirNow = -1; // -1..1 の連続値。符号が反転するまで数百msかかる
const scale = () => Math.min(1, Math.max(0.45, w / REF_W));
const spawn = (onScreen: boolean, rnd: () => number): Petal => {
const s = scale();
const z = rnd();
const size = (15 + rnd() * 26) * (0.62 + z * 0.55) * (0.7 + s * 0.3);
const d = dirRef.current;
return {
x: onScreen ? rnd() * w : d === -1 ? w + size + rnd() * w * 0.8 : -size - rnd() * w * 0.8,
// 半分は上から、半分は風上の側面から入れる。上からだけにすると風下の下半分にだけ
// 溜まって見え、枠の上半分が空いてしまう
y: onScreen ? rnd() * h : rnd() < 0.5 ? -size - rnd() * h * 0.4 : rnd() * h * 0.75 - size,
z,
size,
tone: Math.min(3, Math.floor(rnd() * 4)),
// 大きい(手前の)花びらは風を受けにくく、重く落ちる=勝手に視差になる
wind: Math.max(0.22, Math.min(1, 1.12 - z * 0.62 - rnd() * 0.22)),
vx: 0,
vy: 0,
wave: rnd() * Math.PI * 2,
ax: (rnd() - 0.5) * 0.8,
ay: 0,
az: rnd() * Math.PI * 2,
sx: (rnd() - 0.5) * 0.5,
sz: (rnd() - 0.5) * 0.9,
// 3枚に1枚だけを回り続ける役にする。全部が回ると常時どれかが真横を向き、
// 花びらの形が読めない絵になる(裏返りは「たまに起きる」から効く)
free: rnd() < 0.34,
sy: (rnd() - 0.5) * 1.8,
wob: 0.55 + rnd() * 0.6,
fq: 0.22 + rnd() * 0.35,
ph: rnd() * Math.PI * 2,
};
};
const targetCount = () =>
Math.round(
Math.min(MAX_PETALS, Math.max(16, ((w * h) / 8200) * WINDS[windRef.current].density)),
);
/** 1枚を描く。戻り値は面の見え方(0=面が立っている / 1=真正面) */
const paint = (ctx: CanvasRenderingContext2D, p: Petal) => {
const cz = Math.cos(p.az);
const sz = Math.sin(p.az);
const cy = Math.cos(p.ay);
const sy = Math.sin(p.ay);
const cx = Math.cos(p.ax);
const sxx = Math.sin(p.ax);
// 面内2軸を Z→Y→X の順で回してから画面へ射影する
const ux = cz * cy;
const uy = sz * cx + cz * sy * sxx;
const vx = -sz * cy;
const vy = cz * cx - sz * sy * sxx;
const det = ux * vy - uy * vx;
ctx.globalAlpha = 0.55 + p.z * 0.42;
if (Math.abs(det) < 0.05) {
// 面が真横を向いた瞬間。drawImage では消えるので、板の稜線を1本引いて残す
const len = p.size * 0.5;
ctx.beginPath();
ctx.moveTo(p.x - vx * len, p.y - vy * len);
ctx.lineTo(p.x + vx * len, p.y + vy * len);
ctx.strokeStyle = EDGE;
ctx.lineWidth = Math.max(1, p.size * 0.06);
ctx.stroke();
return det;
}
const sprite = det < 0 ? backs[p.tone] : faces[p.tone];
ctx.save();
ctx.translate(p.x, p.y);
ctx.transform(ux, uy, vx, vy, 0, 0);
ctx.drawImage(sprite, -p.size / 2, -p.size / 2, p.size, p.size);
ctx.restore();
return det;
};
const clear = () => {
bctx.clearRect(0, 0, w, h);
fctx.clearRect(0, 0, w, h);
};
/** 手前層だけ前面 canvas へ。1本の配列を2パスで振り分けるだけ */
const drawAll = () => {
clear();
let edge = 0;
let near = 0;
for (const p of petals) {
const front = p.z > 0.72;
const det = paint(front ? fctx : bctx, p);
if (Math.abs(det) < 0.18) edge += 1;
if (front) near += 1;
}
bctx.globalAlpha = 1;
fctx.globalAlpha = 1;
return { edge, near };
};
const step = (dt: number) => {
const cfg = WINDS[windRef.current];
const s = scale();
const k = 1 - Math.exp(-dt / 520); // 風の立ち上がり・鎮まり
speedNow += (cfg.speed * s - speedNow) * k;
fallNow += (cfg.fall * s - fallNow) * k;
turbNow += (cfg.turbulence - turbNow) * k;
tumbleNow += (cfg.tumble - tumbleNow) * k;
dirNow += (dirRef.current - dirNow) * (1 - Math.exp(-dt / 700));
// 枚数の増減も1フレーム2枚までにして、段を切り替えた瞬間に湧かせない
const target = targetCount();
const rnd = Math.random;
if (petals.length < target) {
for (let i = 0; i < Math.min(2, target - petals.length); i += 1) petals.push(spawn(false, rnd));
} else if (petals.length > target) {
petals.length = Math.max(target, petals.length - 2);
}
const sec = dt / 1000;
const margin = 140;
for (const p of petals) {
const targetVx = speedNow * p.wind * dirNow;
p.vx += (targetVx - p.vx) * (1 - Math.exp(-dt / 320));
p.x += p.vx * sec;
// 落下は「重さ(風を受けにくさ)」で決まる。横揺れの位相に x を混ぜると
// 風に流れるほど揺れが進み、周期が噛み合わない
const heavy = 1.5 - p.wind;
const wave = Math.sin(p.x * 0.012 + p.wave);
const targetVy = fallNow * heavy + wave * turbNow * 26;
p.vy += (targetVy - p.vy) * (1 - Math.exp(-dt / 420));
p.y += p.vy * sec;
const spin = tumbleNow * sec;
p.ax += p.sx * spin;
p.az += p.sz * spin;
if (p.free) {
p.ay += (p.sy + p.vx * 0.004) * spin;
} else {
// 回り続けずに振れるだけの多数派。面はほぼ正面を向いたままひらひらする
p.ph += p.fq * tumbleNow * sec * Math.PI * 2;
p.ay = Math.sin(p.ph) * p.wob;
}
const outX = p.x < -margin || p.x > w + margin;
if (outX || p.y > h + margin) Object.assign(p, spawn(false, rnd));
}
};
/** reduced-motion 用。固定シードで散らした1枚(風の段で密度と傾きが変わる) */
const drawStatic = () => {
if (!ready) return;
const cfg = WINDS[windRef.current];
const rnd = mulberry32(20260927);
const count = targetCount();
petals = [];
for (let i = 0; i < count; i += 1) {
const p = spawn(true, rnd);
p.x = rnd() * (w + 80) - 40;
p.y = rnd() * (h + 80) - 40;
// 風が強いほど流れの向きへ傾いた姿勢で止める(静止画でも風向が読める)
p.az = dirRef.current * (0.25 + cfg.tumble * 0.5) + (rnd() - 0.5) * 0.9;
// 大半は正面寄り、1割ほどが真横〜裏面。動いている絵の分布に合わせる
p.ay = rnd() < 0.12 ? (rnd() - 0.5) * 4.4 : (rnd() - 0.5) * 1.7;
p.ax = (rnd() - 0.5) * 1.1;
petals.push(p);
}
const { edge, near } = drawAll();
setStats({ petals: count, speed: Math.round(cfg.speed * scale()), edge, near });
};
const frame = (t: number) => {
raf = requestAnimationFrame(frame);
if (!ready) return;
const dt = last ? Math.min(64, t - last) : 16;
last = t;
step(dt);
const { edge, near } = drawAll();
if (t - statAt > 280) {
statAt = t;
setStats({ petals: petals.length, speed: Math.round(Math.abs(speedNow * dirNow)), edge, near });
}
};
const start = () => {
if (raf || mql.matches || !visible || !onScreen) return;
last = 0;
raf = requestAnimationFrame(frame);
};
const stop = () => {
if (!raf) return;
cancelAnimationFrame(raf);
raf = 0;
};
let visible = document.visibilityState !== "hidden";
let onScreen = false;
const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
const resize = () => {
// 縮小プレビューの中でも寸法が合うよう、変形後の矩形ではなくレイアウト寸法を採る
if (!stage.clientWidth || !stage.clientHeight) return;
w = stage.clientWidth;
h = stage.clientHeight;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
for (const c of [back, front]) {
c.width = Math.round(w * dpr);
c.height = Math.round(h * dpr);
const ctx = c.getContext("2d");
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
const first = !ready;
ready = true;
if (mql.matches) {
drawStatic();
return;
}
if (first) {
const rnd = Math.random;
petals = Array.from({ length: targetCount() }, () => spawn(true, rnd));
}
// canvas の width/height 代入は中身を消す。rAF が止まっている(画面外・タブ非表示)
// 間に寸法が変わると空白のまま残るので、リサイズのたびに必ず1枚描き直す
const { edge, near } = drawAll();
setStats({ petals: petals.length, speed: Math.round(Math.abs(speedNow)), edge, near });
};
const ro = new ResizeObserver(resize);
ro.observe(stage);
resize();
const io = new IntersectionObserver(
(entries) => {
onScreen = entries.some((e) => e.isIntersecting);
if (onScreen) start();
else stop();
},
{ rootMargin: "120px" },
);
io.observe(stage);
const onVisibility = () => {
visible = document.visibilityState !== "hidden";
if (visible) start();
else stop();
};
const onMotionChange = () => {
setStill(mql.matches);
if (mql.matches) {
stop();
drawStatic();
} else start();
};
// 画面外で風を変えても値が古びないよう、静止時はここで描き直す
apiRef.current = {
refresh: () => {
if (mql.matches) drawStatic();
},
};
document.addEventListener("visibilitychange", onVisibility);
mql.addEventListener("change", onMotionChange);
setStill(mql.matches);
if (mql.matches) drawStatic();
return () => {
stop();
apiRef.current = null;
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
mql.removeEventListener("change", onMotionChange);
};
}, []);
const revealClass = `transition-[opacity,transform] duration-700 ease-out motion-reduce:transition-none ${
inView
? "translate-y-0 opacity-100"
: "translate-y-3 opacity-0 motion-reduce:translate-y-0 motion-reduce:opacity-100"
}`;
// 縦組みは行を配列で持ち flex-row-reverse で並べる(折り返しをブラウザに任せない)
const greeting = ["花のころ、いかがお過ごしですか", "季節のたよりを、ひとひらに"];
const readouts = [
{ label: "舞う枚数", value: stats.petals.toLocaleString("en-US"), unit: "枚" },
{ label: "風速", value: `${stats.speed}`, unit: "px/s" },
{ label: "面が立つ", value: `${stats.edge}`, unit: "枚" },
{ label: "手前層", value: `${stats.near}`, unit: "枚" },
];
return (
<div className="w-full max-w-4xl overflow-hidden rounded-2xl border border-gray-200 bg-white">
{/* 操作卓。堆積のような不可逆な状態は持たず、風の強さと向きだけを切り替える */}
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 border-b border-gray-200 px-4 py-3 sm:px-7 sm:py-4">
<div className="flex items-baseline gap-3">
<span className="font-mono text-[11px] font-bold tracking-[0.12em] text-amber-600">
{WINDS[wind].label}
</span>
<span className="text-[11px] tracking-[0.04em] text-gray-500">{WINDS[wind].note}</span>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 sm:ml-auto">
<div className="flex border border-gray-200">
{WINDS.map((wd, i) => (
<button
key={wd.label}
type="button"
aria-pressed={wind === i}
onClick={() => setWind(i as WindLevel)}
className={`border-l border-gray-200 px-3.5 py-1.5 text-[12px] font-bold transition-colors first:border-l-0 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-amber-600 motion-reduce:transition-none ${
wind === i ? "bg-gray-900 text-white" : "text-gray-500 hover:text-gray-900"
}`}
>
{wd.label}
</button>
))}
</div>
<button
type="button"
onClick={() => setDir((d) => (d === -1 ? 1 : -1))}
className="border border-gray-300 px-4 py-1.5 text-[12px] font-bold text-gray-600 transition-colors hover:border-gray-900 hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
>
風向を変える({dir === -1 ? "右から左へ" : "左から右へ"})
</button>
</div>
</div>
<div ref={stageRef} className="relative min-h-[400px] overflow-hidden sm:min-h-[470px]">
{/* 奥の層。版面の背面に置く */}
<canvas ref={backRef} aria-hidden="true" className="absolute inset-0 block h-full w-full" />
<div className="relative flex flex-col gap-8 px-5 py-9 sm:flex-row sm:items-start sm:gap-10 sm:px-10 sm:py-12">
{/* 縦組みの季節の挨拶。明朝・行は固定 */}
<div
className={`${revealClass} ${zenOldMincho.className} order-1 flex flex-row-reverse justify-end gap-3 self-end sm:order-2 sm:ml-auto sm:self-start`}
>
{greeting.map((line, i) => (
<p
key={line}
style={{ transitionDelay: inView ? `${120 + i * 140}ms` : "0ms" }}
className={`text-[15px] leading-[2.1] tracking-[0.12em] text-gray-900 [writing-mode:vertical-rl] sm:text-[17px] ${
i === 1 ? "pt-7 text-gray-600" : ""
} ${revealClass}`}
>
{line}
</p>
))}
</div>
{/* 本文。背面が白なのでパネルを敷かず、罫線と余白だけでまとめる */}
<div className={`${revealClass} order-2 max-w-sm sm:order-1`}>
<div className="flex items-center gap-3">
<span aria-hidden="true" className="h-px w-8 bg-amber-600" />
<span className="text-[11px] font-bold tracking-[0.1em] text-amber-600">
季節のごあいさつに添える背景
</span>
</div>
<h3 className="mt-4 text-[clamp(1.3rem,4.4vw,1.95rem)] leading-[1.5] font-bold tracking-[0.01em] text-gray-900">
ひらひらと、
<br />
向きを持って落ちる
</h3>
<p className="mt-4 bg-white/85 text-[12.5px] leading-[1.95] text-gray-600 sm:text-[13.5px]">
一枚ずつが面の向きを持っているので、風に乗ると裏が見え、真横を向いた瞬間は線になります。
枚数を増やさなくても密度が出るのは、動きが「落ちる」だけで終わっていないからです。
</p>
<div className="mt-6 flex flex-col items-start gap-2 border-t border-gray-200 pt-4">
<a
href="#"
className="inline-block border-b border-amber-600 pb-1 text-[12.5px] font-bold text-gray-900 transition-colors hover:text-amber-700 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600 motion-reduce:transition-none"
>
季節の挨拶ページを相談する
</a>
<span className="text-[11px] tracking-[0.04em] text-gray-400">
{still ? "モーション低減中は散った一枚を静止表示" : "風の強さと向きは上のボタンで切り替わります"}
</span>
</div>
</div>
</div>
{/* 手前の層。版面より上に重なり、本文の前を横切る */}
<canvas
ref={frontRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0 block h-full w-full"
/>
</div>
{/* 計測帯。カードは使わず hairline 罫線で区切った等幅の数値だけ並べる */}
<dl className="grid grid-cols-2 border-t border-gray-200 sm:grid-cols-4">
{readouts.map((r, i) => (
<div
key={r.label}
className={`flex items-baseline justify-between gap-2 px-4 py-3 sm:px-7 sm:py-4 ${
i % 2 === 1 ? "border-l border-gray-200" : ""
} ${i >= 2 ? "border-t border-gray-200 sm:border-t-0" : ""} ${
i === 2 ? "sm:border-l sm:border-gray-200" : ""
}`}
>
<dt className="text-[11px] tracking-[0.08em] text-gray-500">{r.label}</dt>
<dd className="font-mono text-[13px] font-bold text-gray-900 tabular-nums">
{r.value}
<span className="ml-0.5 text-[10px] font-medium text-gray-400">{r.unit}</span>
</dd>
</div>
))}
</dl>
</div>
);
}
shadcn CLI でプロジェクトに追加
npx shadcn@latest add https://designs.first-ch.com/r/petal-fall-seasonal-backdrop.json