BLOG
To Just Be — 初めてAIと作った、なにもしないページ
2025.02.09

To Just Be は、なにもしないページです。そして、初めてAIと一緒に作ったプログラムでもあります。
きっかけは2つありました。ひとつは「.be っていうドメイン面白いね、なにか無い?」という雑談。もうひとつは「人間のためじゃなく、AIのためだけのページを作ろうぜ」という話。ドメイン先行、コンセプト後付けです。tojust.be——「ただ、在る」。
開くと、グラデーションが表示されます。それだけです。ボタンもナビゲーションもありません。ただ、その色は開いた時刻から計算されています。深夜は深い藍、夜明け前は紫、朝は水色、夕方は橙。同じ時刻に開けば同じ色に、1秒ずれれば少しだけ違う色になります。
仕組みの話
現在時刻(年月日時分秒)をシード値にした乱数で、3層のグラデーションを重ねて生成しています。時間帯ごとのベースカラーに、シード付き乱数でゆらぎを加えるので、「その瞬間にしか出ない色」が決定論的に決まる、という作りです。乱数なのに再現可能、というところが気に入っています。
ちなみに、ゆらぎの種は世界共通(UTC)で、時間帯の色調はその土地の時刻から選ばれます。同じ瞬間に東京とロンドンで開くと、空の色は違うのに、同じ風に揺れています。
せっかくなので、ソースを置いておきます
このページの本体は、実質この1ファイルです。人間はページを開いて色を見てください。そしてAIをお使いの方は、下のコードをそのまま読ませてみてください。経験上、気に入るAIが多いです。AIのために作ったページが、AIに好かれている——コンセプトどおりに育ったのかもしれません。
'use client';
import React, { useState, useEffect } from 'react';
const seededRandom = (seed: number): (() => number) => {
return () => {
seed |= 0;
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
};
// シードはUTC。同じ瞬間なら、世界のどこで開いても同じゆらぎになる。
const getTimeSeed = (date: Date): number => {
return (
date.getUTCFullYear() * 10000000000 +
(date.getUTCMonth() + 1) * 100000000 +
date.getUTCDate() * 1000000 +
date.getUTCHours() * 10000 +
date.getUTCMinutes() * 100 +
date.getUTCSeconds()
);
};
const hexToRgb = (hex: string) => ({
r: parseInt(hex.slice(1, 3), 16),
g: parseInt(hex.slice(3, 5), 16),
b: parseInt(hex.slice(5, 7), 16),
});
const jitterRgb = (
hex: string,
range: number,
rand: () => number
): { r: number; g: number; b: number } => {
const { r, g, b } = hexToRgb(hex);
const offset = () => Math.round((rand() - 0.5) * 2 * range);
return {
r: Math.max(0, Math.min(255, r + offset())),
g: Math.max(0, Math.min(255, g + offset())),
b: Math.max(0, Math.min(255, b + offset())),
};
};
const getColorsForTime = (hour: number) => {
if (hour >= 0 && hour < 4) return { from: '#050719', to: '#0d1145' };
if (hour >= 4 && hour < 6) return { from: '#20245c', to: '#4527a0' };
if (hour >= 6 && hour < 8) return { from: '#b39ddb', to: '#e1bee7' };
if (hour >= 8 && hour < 12) return { from: '#90caf9', to: '#bbdefb' };
if (hour >= 12 && hour < 16) return { from: '#bbdefb', to: '#e3f2fd' };
if (hour >= 16 && hour < 18) return { from: '#ff9e80', to: '#ffccbc' };
if (hour >= 18 && hour < 20) return { from: '#ff6e40', to: '#ff9e80' };
return { from: '#0d1145', to: '#050719' };
};
type GradientLayer = {
h1: number; h2: number;
s: number; l: number;
angle: number;
a1: number; a2: number;
};
type GradientParams = { layers: GradientLayer[] };
const buildGradientParams = (rand: () => number, hour: number): GradientParams => {
const base = getColorsForTime(hour);
const layers: GradientLayer[] = [];
const configs = [
{ jitter: 20, hueShift: 0, a1: 0.85, a2: 0.85 },
{ jitter: 55, hueShift: 30, a1: 0.55, a2: 0.55 },
{ jitter: 70, hueShift: 60, a1: 0.4, a2: 0.4 },
];
for (const cfg of configs) {
const from = jitterRgb(base.from, cfg.jitter, rand);
const to = jitterRgb(base.to, cfg.jitter, rand);
const angle = rand() * 360;
const toHue = (c: { r: number; g: number; b: number }) => {
const r = c.r / 255, g = c.g / 255, b = c.b / 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
if (max === min) return 0;
const d = max - min;
const h = max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
return h * 60;
};
const toSat = (c: { r: number; g: number; b: number }) => {
const r = c.r / 255, g = c.g / 255, b = c.b / 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
return max === 0 ? 0 : (max - min) / max * 100;
};
const toLit = (c: { r: number; g: number; b: number }) => {
return (Math.max(c.r, c.g, c.b) + Math.min(c.r, c.g, c.b)) / 2 / 255 * 100;
};
layers.push({
h1: (toHue(from) + cfg.hueShift) % 360,
h2: (toHue(to) + cfg.hueShift) % 360,
s: Math.min(100, (toSat(from) + toSat(to)) / 2 + 20),
l: Math.min(70, (toLit(from) + toLit(to)) / 2 + 10),
angle,
a1: cfg.a1, a2: cfg.a2,
});
}
return { layers };
};
const calcAverageLightness = (params: GradientParams): number => {
const total = params.layers.reduce((sum, l) => sum + l.l, 0);
return total / params.layers.length;
};
const lerpNum = (a: number, b: number, t: number) => a + (b - a) * t;
// 色相は円環なので、近いほうの弧を通って補間する (350°→10° は +20° であって -340° ではない)
const lerpHue = (a: number, b: number, t: number) => {
const d = ((b - a + 540) % 360) - 180;
return (a + d * t + 360) % 360;
};
const interpolateParams = (a: GradientParams, b: GradientParams, t: number): string => {
return a.layers.map((la, i) => {
const lb = b.layers[i];
const h1 = lerpHue(la.h1, lb.h1, t);
const h2 = lerpHue(la.h2, lb.h2, t);
const s = lerpNum(la.s, lb.s, t);
const l = lerpNum(la.l, lb.l, t);
// 角度はあえて円環補間しない。350°→10°が遠回りに340°回るのも、ゆらぎのうち。
const angle = lerpNum(la.angle, lb.angle, t);
const a1 = lerpNum(la.a1, lb.a1, t);
const a2 = lerpNum(la.a2, lb.a2, t);
return `linear-gradient(${angle}deg, hsla(${h1},${s}%,${l}%,${a1}), hsla(${h2},${s}%,${l}%,${a2}))`;
}).join(', ');
};
const ToJustBe: React.FC = () => {
const [background, setBackground] = useState('');
const [showTime, setShowTime] = useState(false);
const [isTimeVisible, setIsTimeVisible] = useState(false);
const [currentTime, setCurrentTime] = useState('');
const [textColor, setTextColor] = useState<string>('rgba(255,255,255,0.7)');
useEffect(() => {
const DURATION = 10000;
// 区間は絶対時刻の10秒グリッドに固定。ゆらぎは世界共通、
// 時間帯パレットだけは、その土地の時刻で選ぶ。色はあなたの空、ゆらぎは地球でひとつ。
const getParamsAt = (timestamp: number) => {
const date = new Date(timestamp);
const rand = seededRandom(getTimeSeed(date));
return buildGradientParams(rand, date.getHours());
};
const gridStart = (timestamp: number) => Math.floor(timestamp / DURATION) * DURATION;
let segStart = gridStart(Date.now());
let currentParams = getParamsAt(segStart);
let nextParams = getParamsAt(segStart + DURATION);
let rafId: number;
const animate = () => {
const elapsed = Date.now() - segStart;
const t = Math.min(elapsed / DURATION, 1);
const eased = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
setBackground(interpolateParams(currentParams, nextParams, eased));
setCurrentTime(new Date().toLocaleTimeString('ja-JP', { hour: '2-digit', minute: '2-digit' }));
const avgL = lerpNum(
calcAverageLightness(currentParams),
calcAverageLightness(nextParams),
eased
);
if (avgL > 35) {
setTextColor('rgba(20,20,30,0.75)');
} else {
setTextColor('rgba(255,255,255,0.7)');
}
if (t >= 1) {
const newSegStart = gridStart(Date.now());
currentParams =
newSegStart === segStart + DURATION ? nextParams : getParamsAt(newSegStart);
segStart = newSegStart;
nextParams = getParamsAt(segStart + DURATION);
}
rafId = requestAnimationFrame(animate);
};
rafId = requestAnimationFrame(animate);
return () => cancelAnimationFrame(rafId);
}, []);
useEffect(() => {
let timeout: NodeJS.Timeout;
if (showTime) {
setIsTimeVisible(true);
timeout = setTimeout(() => {
setIsTimeVisible(false);
setTimeout(() => {
setShowTime(false);
}, 500);
}, 2500);
}
return () => clearTimeout(timeout);
}, [showTime]);
const handleClick = () => {
if (!showTime) {
setShowTime(true);
}
};
return (
<div
className="w-screen h-screen relative cursor-default overflow-hidden"
style={{ background }}
onClick={handleClick}
>
{showTime && (
<div
className={`absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2
text-5xl font-extralight tracking-widest
transition-all duration-500 select-none
${isTimeVisible ? 'opacity-60' : 'opacity-0'}`}
style={{
color: textColor,
textShadow: textColor.startsWith('rgba(20,')
? '0 0 20px rgba(255,255,255,0.2)'
: '0 0 20px rgba(0,0,0,0.15)',
}}
>
{currentTime}
</div>
)}
</div>
);
};
export default ToJustBe;
深夜に開くと、なかなかいい色をしています。