CSS
アニメーション
2026/02/11
2026/2/11
デジタル時計は便利だけど、時間の流れそのものはあまり感じられません。
そこで今回は、時間を「数字」ではなく「円の回転」で見せる時計を作ります。
秒・分・時刻だけでなく、日付・月・年・曜日までもリングとして重ね、現在の値に合わせてゆっくり回転。
さらに中央の国旗ボタンから言語とタイムゾーンを切り替えることで、世界の“いま”をワンタップで覗けるインタラクティブな時計になっています。
<div class="kumonosu-clock">
<div>
<div data-clock="years" data-numbers="101" class="kumonosu-clock-face"></div>
</div>
<div>
<div data-clock="seconds" data-numbers="60" class="kumonosu-clock-face"></div>
</div>
<div>
<div data-clock="minutes" data-numbers="60" class="kumonosu-clock-face"></div>
</div>
<div>
<div data-clock="hours" data-numbers="24" class="kumonosu-clock-face"></div>
</div>
<div>
<div data-clock="days" data-numbers="31" class="kumonosu-clock-face"></div>
</div>
<div>
<div data-clock="months" data-numbers="12" class="kumonosu-clock-face"></div>
</div>
<div>
<div data-clock="day-names" data-numbers="7" class="kumonosu-clock-face"></div>
</div>
<button type="button" id="kumonosu-current-lang" class="kumonosu-current-lang-display">ja</button>
</div>
<dialog id="kumonosu-language-dialog">
<button type="button" id="kumonosu-btn-dialog-close" class="kumonosu-btn-dialog-close" autofocus>✕</button>
<div id="kumonosu-language-options" class="kumonosu-language-options"></div>
</dialog>
*,
::before,
::after {
box-sizing: border-box;
}
:root {
--bg-clr: rgb(255 255 255);
--clock-size: 800px;
--theme-color: #b30000;
--clock-numbers-clr: #000000;
--active-numbers-clr: #000000;
--clock-mask-opacity: .85;
--clock-mask-inset: 2px;
--clock-dialog-bg-clr: rgba(255, 255, 255, 0.9);
}
body {
margin: 0;
min-height: 100svh;
display: grid;
place-content: center;
font-family: system-ui, -apple-system, sans-serif;
background-color: var(--bg-clr);
transition: background-color 500ms ease;
overflow: hidden;
}
.kumonosu-clock {
position: fixed;
place-content: center;
inset: 0;
margin: auto;
width: var(--clock-size);
height: var(--clock-size);
aspect-ratio: 1;
border-radius: 50%;
}
@media (width < 800px) {
.kumonosu-clock {
left: 0;
right: auto;
translate: calc((50% - 2rem) * -1) 0;
}
}
.kumonosu-clock::before {
content: "";
position: absolute;
inset: var(--clock-mask-inset);
margin: auto;
border-radius: 50%;
z-index: 20;
clip-path: polygon(0 0,
100% 0,
100% 48%,
50% 48%,
50% 52%,
100% 52%,
100% 100%,
0 100%);
background-color: var(--theme-color);
opacity: var(--clock-mask-opacity, .75);
transition: background-color 500ms ease;
}
.kumonosu-clock>div {
position: absolute;
inset: 0;
margin: auto;
width: var(--kumonosu-clock-d);
height: var(--kumonosu-clock-d);
font-size: var(--f-size, 0.9rem);
aspect-ratio: 1;
isolation: isolate;
border-radius: 50%;
}
.kumonosu-clock>div:nth-of-type(1) {
--kumonosu-clock-d: calc(var(--clock-size) - 20px);
}
.kumonosu-clock>div:nth-of-type(2) {
--kumonosu-clock-d: calc(var(--clock-size) - 130px);
}
.kumonosu-clock>div:nth-child(3) {
--kumonosu-clock-d: calc(var(--clock-size) - 195px);
}
.kumonosu-clock>div:nth-child(4) {
--kumonosu-clock-d: calc(var(--clock-size) - 260px);
}
.kumonosu-clock>div:nth-child(5) {
--kumonosu-clock-d: calc(var(--clock-size) - 350px);
}
.kumonosu-clock>div:nth-child(6) {
--kumonosu-clock-d: calc(var(--clock-size) - 470px);
}
.kumonosu-clock>div:nth-child(7) {
--kumonosu-clock-d: calc(var(--clock-size) - 600px);
}
.kumonosu-clock-face {
position: relative;
width: 100%;
height: 100%;
aspect-ratio: 1;
border-radius: 50%;
transition: 300ms linear;
}
.kumonosu-clock-face>* {
position: absolute;
transform-origin: center;
white-space: nowrap;
color: var(--clock-numbers-clr);
opacity: 0.75;
transition: color 500ms ease;
}
.kumonosu-clock-face>.kumonosu-active {
opacity: 1;
font-weight: bold;
color: var(--active-numbers-clr);
}
.kumonosu-clock>.kumonosu-current-lang-display {
position: absolute;
inset: 0;
margin: auto;
z-index: 100;
display: grid;
place-content: center;
background-color: white;
border: 2px solid var(--theme-color);
border-radius: 50%;
width: 44px;
height: 44px;
cursor: pointer;
transition: 300ms ease-in-out;
font-size: 1.5rem;
outline: none;
}
.kumonosu-current-lang-display::before,
.kumonosu-current-lang-display::after {
content: ":";
color: var(--clock-numbers-clr);
position: absolute;
z-index: 199;
top: 50%;
right: 0;
font-size: 0.9rem;
translate: 283px -10px;
}
.kumonosu-current-lang-display::after {
translate: 250px -10px;
}
#kumonosu-language-dialog {
width: min(calc(100% - 2rem), 380px);
padding: 1rem;
border: none;
border-radius: 50%;
background: var(--clock-dialog-bg-clr);
text-align: center;
aspect-ratio: 1;
overflow: visible;
transition: opacity 500ms ease-in, scale 500ms cubic-bezier(0.28, -0.55, 0.27, 1.55);
}
#kumonosu-language-dialog:not([open]) {
opacity: 0;
scale: 0;
display: none;
}
#kumonosu-language-dialog[open]::backdrop {
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(3px);
}
#kumonosu-language-dialog .kumonosu-btn-dialog-close {
position: absolute;
top: 0rem;
right: 25%;
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #333;
font-size: 1.2rem;
color: white;
border: none;
cursor: pointer;
transition: rotate 300ms ease-in-out;
z-index: 11;
}
#kumonosu-language-dialog .kumonosu-btn-dialog-close:hover {
rotate: 90deg;
}
.kumonosu-language-options {
position: absolute;
inset: 0;
margin: auto;
border-radius: 50%;
aspect-ratio: 1/1;
}
.kumonosu-language-options>label {
position: absolute;
transform: translate(-50%, -50%);
cursor: pointer;
font-size: 0.9rem;
width: 36px;
height: 36px;
transition: 300ms ease-in-out;
display: grid;
place-content: center;
border-radius: 50%;
}
.kumonosu-language-options>label.kumonosu-active {
color: white;
background: var(--theme-color);
}
.kumonosu-language-options>label:hover {
scale: 1.2;
z-index: 2;
}
.kumonosu-language-title {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
color: #333;
font-size: 1rem;
width: 80%;
text-align: center;
}
.kumonosu-flag-icon {
font-size: 1.5rem;
display: grid;
place-content: center;
}
.kumonosu-language-options input[type="radio"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
const languageFlags = [{
code: 'ar-SA',
name: 'Arabic',
flag: '🇸🇦',
bg: '#FFFFFF',
mask: '#006C35'
}, // 白 -> 緑
{
code: 'cs-CZ',
name: 'Czech',
flag: '🇨🇿',
bg: '#FFFFFF',
mask: '#D7141A'
}, {
code: 'da-DK',
name: 'Danish',
flag: '🇩🇰',
bg: '#FFFFFF',
mask: '#C60C30'
}, // 白 -> 赤
{
code: 'de-DE',
name: 'German',
flag: '🇩🇪',
bg: '#000000',
mask: '#FFCE00'
}, {
code: 'el-GR',
name: 'Greek',
flag: '🇬🇷',
bg: '#FFFFFF',
mask: '#005BAE'
}, // 白 -> 青
{
code: 'en-US',
name: 'English (US)',
flag: '🇺🇸',
bg: '#3C3B6E',
mask: '#B22234'
}, {
code: 'en-GB',
name: 'English (UK)',
flag: '🇬🇧',
bg: '#012169',
mask: '#C8102E'
}, {
code: 'es-ES',
name: 'Spanish',
flag: '🇪🇸',
bg: '#C60B1E',
mask: '#FFD700'
}, {
code: 'es-MX',
name: 'Spanish (MX)',
flag: '🇲🇽',
bg: '#006847',
mask: '#CE1126'
}, {
code: 'fi-FI',
name: 'Finnish',
flag: '🇫🇮',
bg: '#FFFFFF',
mask: '#003580'
}, {
code: 'fr-CA',
name: 'French (CA)',
flag: '🇨🇦',
bg: '#FFFFFF',
mask: '#FF0000'
}, // 白 -> 赤
{
code: 'fr-FR',
name: 'French (FR)',
flag: '🇫🇷',
bg: '#002395',
mask: '#ED2939'
}, {
code: 'he-IL',
name: 'Hebrew',
flag: '🇮🇱',
bg: '#FFFFFF',
mask: '#0038B8'
}, {
code: 'hi-IN',
name: 'Hindi',
flag: '🇮🇳',
bg: '#FF9933',
mask: '#138808'
}, {
code: 'hu-HU',
name: 'Hungarian',
flag: '🇭🇺',
bg: '#436F4D',
mask: '#CE1126'
}, // 白 -> 赤
{
code: 'it-IT',
name: 'Italian',
flag: '🇮🇹',
bg: '#009246',
mask: '#CD212A'
}, {
code: 'ja-JP',
name: 'Japanese',
flag: '🇯🇵',
bg: '#FFFFFF',
mask: '#b30000'
}, {
code: 'ko-KR',
name: 'Korean',
flag: '🇰🇷',
bg: '#FFFFFF',
mask: '#CD2E3A'
}, {
code: 'nl-NL',
name: 'Dutch',
flag: '🇳🇱',
bg: '#AE1C28',
mask: '#21468B'
}, {
code: 'no-NO',
name: 'Norwegian',
flag: '🇳🇴',
bg: '#BA0C2F',
mask: '#00205B'
}, {
code: 'pl-PL',
name: 'Polish',
flag: '🇵🇱',
bg: '#FFFFFF',
mask: '#DC143C'
}, {
code: 'pt-BR',
name: 'Portuguese (BR)',
flag: '🇧🇷',
bg: '#009739',
mask: '#FEDF00'
}, {
code: 'pt-PT',
name: 'Portuguese (PT)',
flag: '🇵🇹',
bg: '#FF0000',
mask: '#006600'
}, {
code: 'ro-RO',
name: 'Romanian',
flag: '🇷🇴',
bg: '#002B7F',
mask: '#FCD116'
}, {
code: 'ru-RU',
name: 'Russian',
flag: '🇷🇺',
bg: '#FFFFFF',
mask: '#DA2128'
}, {
code: 'sv-SE',
name: 'Swedish',
flag: '🇸🇪',
bg: '#006AA7',
mask: '#FECC00'
}, {
code: 'th-TH',
name: 'Thai',
flag: '🇹🇭',
bg: '#2D2A4A',
mask: '#A51931'
}, // 白 -> 赤
{
code: 'tr-TR',
name: 'Turkish',
flag: '🇹🇷',
bg: '#FFFFFF',
mask: '#E30A17'
}, // 白 -> 赤
{
code: 'vi-VN',
name: 'Vietnamese',
flag: '🇻🇳',
bg: '#DA251D',
mask: '#FFFF00'
}, {
code: 'zh-CN',
name: 'Chinese',
flag: '🇨🇳',
bg: '#EE1C25',
mask: '#FFFF00'
},
];
const RADIUS_LANG = 140;
const defaultRegions = languageFlags.reduce((map, lang) => {
const baseLang = lang.code.split('-')[0];
if (!map[baseLang]) map[baseLang] = lang.code;
return map;
}, {});
function getLocale() {
let language = (navigator.languages && navigator.languages[0]) || navigator.language || 'ja-JP';
if (language.length === 2) {
language = defaultRegions[language] || `${language}-${language.toUpperCase()}`;
}
return language;
}
let locale = getLocale();
const currentLangDisplay = document.getElementById('kumonosu-current-lang');
const languageDialog = document.getElementById('kumonosu-language-dialog');
const languageOptionsContainer = document.getElementById('kumonosu-language-options');
const closeButton = document.getElementById('kumonosu-btn-dialog-close');
function drawClockFaces() {
const clockFaces = document.querySelectorAll('.kumonosu-clock-face');
const currentDate = new Date();
const currentDay = currentDate.getDate();
const currentMonth = currentDate.getMonth();
const currentYear = currentDate.getFullYear();
const currentWeekday = currentDate.getDay();
const currentHours = currentDate.getHours();
const currentMinutes = currentDate.getMinutes();
const currentSeconds = currentDate.getSeconds();
const totalDaysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate();
const weekdayNames = Array.from({
length: 7
}, (_, i) => new Intl.DateTimeFormat(locale, {
weekday: 'long'
}).format(new Date(2021, 0, i + 3)));
const monthNames = Array.from({
length: 12
}, (_, i) => new Intl.DateTimeFormat(locale, {
month: 'long'
}).format(new Date(2021, i)));
clockFaces.forEach(clockFace => {
clockFace.innerHTML = '';
const clockType = clockFace.getAttribute('data-clock');
const numbers = parseInt(clockFace.getAttribute('data-numbers'), 10);
const RADIUS = (clockFace.offsetWidth / 2) - 20;
const center = clockFace.offsetWidth / 2;
let valueSet;
let currentValue;
switch (clockType) {
case 'seconds':
valueSet = Array.from({
length: 60
}, (_, i) => String(i).padStart(2, '0'));
currentValue = String(currentSeconds).padStart(2, '0');
break;
case 'minutes':
valueSet = Array.from({
length: 60
}, (_, i) => String(i).padStart(2, '0'));
currentValue = String(currentMinutes).padStart(2, '0');
break;
case 'hours':
valueSet = Array.from({
length: 24
}, (_, i) => String(i).padStart(2, '0'));
currentValue = String(currentHours).padStart(2, '0');
break;
case 'days':
valueSet = Array.from({
length: totalDaysInMonth
}, (_, i) => i + 1);
currentValue = currentDay;
break;
case 'months':
valueSet = monthNames;
currentValue = currentMonth;
break;
case 'years':
valueSet = Array.from({
length: 101
}, (_, i) => 2000 + i);
currentValue = currentYear;
break;
case 'day-names':
valueSet = weekdayNames;
currentValue = currentWeekday;
break;
default:
return;
}
valueSet.forEach((value, i) => {
const angle = (i * (360 / numbers));
const x = center + RADIUS * Math.cos((angle * Math.PI) / 180);
const y = center + RADIUS * Math.sin((angle * Math.PI) / 180);
const element = document.createElement('span');
element.classList.add('kumonosu-number');
element.textContent = value;
element.style.left = `${x}px`;
element.style.top = `${y}px`;
element.style.transform = `translate(-50%, -50%) rotate(${angle}deg)`;
clockFace.appendChild(element);
});
const currentIndex = valueSet.indexOf(typeof valueSet[0] === 'string' ? String(currentValue) : currentValue);
const rotationAngle = -((currentIndex / numbers) * 360);
clockFace.style.transform = `rotate(${rotationAngle}deg)`;
});
}
const lastAngles = {};
function rotateClockFaces() {
const clockFaces = document.querySelectorAll('.kumonosu-clock-face');
function updateRotations() {
const now = new Date();
const currentSecond = now.getSeconds();
const currentMinute = now.getMinutes();
const currentHour = now.getHours();
const currentDay = now.getDate();
const currentMonth = now.getMonth();
const currentYear = now.getFullYear();
const currentWeekday = now.getDay();
clockFaces.forEach(clockFace => {
const clockType = clockFace.getAttribute('data-clock');
const totalNumbers = parseInt(clockFace.getAttribute('data-numbers'), 10);
let currentValue;
switch (clockType) {
case 'seconds':
currentValue = currentSecond;
break;
case 'minutes':
currentValue = currentMinute;
break;
case 'hours':
currentValue = currentHour;
break;
case 'days':
currentValue = currentDay - 1;
break;
case 'months':
currentValue = currentMonth;
break;
case 'years':
currentValue = currentYear - 2000;
break;
case 'day-names':
currentValue = currentWeekday;
break;
default:
return;
}
const targetAngle = (360 / totalNumbers) * currentValue;
const clockId = clockFace.id || clockType;
const lastAngle = lastAngles[clockId] || 0;
const delta = targetAngle - lastAngle;
const shortestDelta = ((delta + 540) % 360) - 180;
const newAngle = lastAngle + shortestDelta;
clockFace.style.transform = `rotate(${newAngle * -1}deg)`;
lastAngles[clockId] = newAngle;
const numbers = clockFace.querySelectorAll('.kumonosu-number');
numbers.forEach((number, index) => {
if (index === currentValue) number.classList.add('kumonosu-active');
else number.classList.remove('kumonosu-active');
});
});
requestAnimationFrame(updateRotations);
}
updateRotations();
}
function createLanguageOptions() {
const centerX = languageOptionsContainer.offsetWidth / 2;
const centerY = languageOptionsContainer.offsetHeight / 2;
languageOptionsContainer.innerHTML = '';
languageFlags.forEach((lang, index, arr) => {
const angle = (index / arr.length) * 2 * Math.PI;
const x = centerX + RADIUS_LANG * Math.cos(angle);
const y = centerY + RADIUS_LANG * Math.sin(angle);
const radioWrapper = document.createElement('label');
radioWrapper.title = lang.name;
radioWrapper.style.left = `${x}px`;
radioWrapper.style.top = `${y}px`;
const radioInput = document.createElement('input');
radioInput.type = 'radio';
radioInput.name = 'language';
radioInput.value = lang.code;
if (lang.code === locale) {
radioInput.checked = true;
radioWrapper.classList.add('kumonosu-active');
}
const flag = document.createElement('span');
flag.classList.add('kumonosu-flag-icon');
flag.innerText = lang.flag;
radioWrapper.appendChild(radioInput);
radioWrapper.appendChild(flag);
languageOptionsContainer.appendChild(radioWrapper);
radioWrapper.addEventListener('mouseover', () => showTitle(lang.name));
radioWrapper.addEventListener('mouseleave', () => hideTitle());
radioInput.addEventListener('change', () => {
locale = radioInput.value;
setCurrentLangDisplay(lang);
drawClockFaces();
document.querySelector('label.kumonosu-active')?.classList.remove('kumonosu-active');
radioWrapper.classList.add('kumonosu-active');
closeDialog();
});
});
}
let titleDisplay = null;
function showTitle(languageName) {
if (!titleDisplay) {
titleDisplay = document.createElement('div');
titleDisplay.classList.add('kumonosu-language-title');
languageOptionsContainer.appendChild(titleDisplay);
}
titleDisplay.textContent = languageName;
}
function hideTitle() {
if (titleDisplay) titleDisplay.textContent = '';
}
function getContrastColor(hexcolor) {
if (!hexcolor || hexcolor === 'transparent') return '#000000';
const hex = hexcolor.replace('#', '');
const r = parseInt(hex.substr(0, 2), 16);
const g = parseInt(hex.substr(2, 2), 16);
const b = parseInt(hex.substr(4, 2), 16);
const yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000;
return (yiq >= 128) ? '#000000' : '#FFFFFF';
}
function setCurrentLangDisplay(lang) {
if (lang) {
currentLangDisplay.textContent = lang.flag;
currentLangDisplay.title = lang.name;
document.documentElement.style.setProperty('--bg-clr', lang.bg);
document.documentElement.style.setProperty('--theme-color', lang.mask);
const normalTextClr = getContrastColor(lang.bg);
document.documentElement.style.setProperty('--clock-numbers-clr', normalTextClr);
let activeTextClr;
if (normalTextClr === '#000000') {
activeTextClr = '#000000';
} else {
activeTextClr = getContrastColor(lang.mask);
}
document.documentElement.style.setProperty('--active-numbers-clr', activeTextClr);
}
}
function openDialog() {
languageDialog.showModal();
createLanguageOptions();
languageDialog.addEventListener('click', closeDialogOnClickOutside);
}
function closeDialog() {
languageDialog.close();
languageDialog.removeEventListener('click', closeDialogOnClickOutside);
}
function closeDialogOnClickOutside(e) {
if (e.target === languageDialog) closeDialog();
}
closeButton.addEventListener('click', closeDialog);
currentLangDisplay.addEventListener('click', openDialog);
// Initialize
drawClockFaces();
rotateClockFaces();
setCurrentLangDisplay(languageFlags.find(lang => lang.code === locale) || languageFlags.find(l => l.code === 'ja-JP'));
この作品は、世界時計を「都市名のリスト」ではなく、円形のリングが回転するビジュアルとして表現したUIです。
時間の各要素(秒・分・時・日・月・年・曜日)をそれぞれ別レイヤーに分け、現在値に合わせてリング全体が回転します。
数字が切り替わるのではなく“リングが動く”ため、眺めているだけで時間の流れが感じられるのが特徴です。
drawClockFaces() で円周上に並べて生成rotateClockFaces() で常時更新requestAnimationFrame() で滑らかに追従<dialog> で選択Intl.DateTimeFormat(currentLocale))getTimeInZone(tz))この時計の“世界時計らしさ”は、ここが肝です。
languageFlags に locale(code)と timeZone(tz) をセットcurrentTimeZone を使って、Intl.DateTimeFormat の formatToParts() からつまり、端末の現在時刻をベースに「指定タイムゾーンの現在」を組み立て直すことで、各国の“いま”を実現しています。
--clock-size を基準にして、リングを段階的に小さくする設計clip-path で作ったマスク表現.kumonosu-clock-face { transition: 500ms cubic-bezier(...) }:root { --clock-size: 800px; }
大きいほど余白が取れて読みやすく、小さいほどUI感が強くなります。
.kumonosu-clock > div { font-size: 0.7rem; }
年月リングは情報量が多いので、ここを少し下げるのも手です。
languageFlags に1行追加するだけでOKです。
{ code: 'en-AU', name: 'Australia', flag: '🇦🇺', bg: '#001B5A', mask: '#FFB300', tz: 'Australia/Sydney' }
bg と mask の2色で雰囲気が激変します。
背景が暗い国は、文字色が自動で白寄りになるよう getContrastColor() が効きます。
Intl.DateTimeFormat と timeZone は環境依存がありますrequestAnimationFrame を setInterval(250ms) にする など<dialog> 未対応ブラウザ向けには、モーダルをdivで作るフォールバックが必要です