component

Clock

Procedural analog wall clock; hands track the system clock or a given IANA timezone.

⌖ Explore the library in 3D → · View as Markdown

Tune every prop in the workshop →

Add it

npx @runek/cli add clock

Pulls @react-three/fiber@^9.6.1, @runek/core@^0.15.0, three@^0.184.0.

Use it

import { Clock } from './runek/Clock'

<Clock position={[0, 0, 0]} />

Or as a node in a world file (worlds as data):

{ "type": "Clock", "props": { "position": [0, 0, 0] } }

Props

Prop Type Default Description
position [x, y, z] [0, 0, 0] Position [x, y, z] in units (1 unit = 1 m, Y-up).
rotation [x, y, z] [0, 0, 0] Euler rotation [x, y, z] in radians.
seed number Accepted for the component contract; this component has no seeded variation yet.
radius number 0.55 Face radius in units.
timezone string IANA timezone, e.g. “Asia/Kolkata”. Omit to inherit the world’s timezone (<World timezone>), then the local system time; if neither resolves, the clock falls back to UTC.
frameColor color palette metal Rim/frame color. Defaults to the world palette’s metal slot.
faceColor color "#0d1117" Dial color.
handColor color "#e8eef5" Hour/minute hand and tick color.
accentColor color palette accent Second hand + hub accent. Defaults to the world palette’s accent slot.

Registry manifest

registry manifest /r/components/clock.json Self-contained JSON: inlined source plus resolved dependencies, exactly what runek add clock fetches.

Browse the whole catalog in the gallery →.

Source (Clock.tsx)
import { useFrame } from '@react-three/fiber'
import { useWorld, type Vec3 } from '@runek/core'
import { useMemo, useRef } from 'react'
import type { Group } from 'three'

export interface ClockProps {
  position?: Vec3
  rotation?: Vec3
  /** Accepted for the component contract; this component has no seeded variation yet. */
  seed?: number
  /** Face radius in units. */
  radius?: number
  /**
   * IANA timezone, e.g. "Asia/Kolkata". Omit to inherit the world's `timezone`
   * (`<World timezone>`), then the local system time; if neither resolves, the
   * clock falls back to UTC.
   */
  timezone?: string
  /** Rim/frame color. Defaults to the world palette's `metal` slot. */
  frameColor?: string
  /** Dial color. */
  faceColor?: string
  /** Hour/minute hand and tick color. */
  handColor?: string
  /** Second hand + hub accent. Defaults to the world palette's `accent` slot. */
  accentColor?: string
}

/**
 * Read hours/minutes/seconds for a timezone (or the local clock), with a UTC
 * fallback. The sub-second fraction is timezone-independent, so we add it back
 * for a smooth second-hand sweep.
 */
function readTime(fmt: Intl.DateTimeFormat | null): { h: number; m: number; s: number } {
  const now = new Date()
  const frac = (now.getTime() % 1000) / 1000
  try {
    if (fmt) {
      const part: Record<string, number> = {}
      for (const p of fmt.formatToParts(now)) part[p.type] = Number(p.value)
      const h = part.hour % 24
      if (![h, part.minute, part.second].every(Number.isFinite)) throw new Error('bad zone')
      return { h, m: part.minute, s: part.second + frac }
    }
    const h = now.getHours()
    const m = now.getMinutes()
    const s = now.getSeconds()
    if (![h, m, s].every(Number.isFinite)) throw new Error('no local time')
    return { h, m, s: s + frac }
  } catch {
    return { h: now.getUTCHours(), m: now.getUTCMinutes(), s: now.getUTCSeconds() + frac }
  }
}

/**
 * A procedural analog clock — face, rim, ticks, and three hands, all from code
 * (no fonts or textures). The hands track the system clock, or a given `timezone`.
 * Decorative: it registers no colliders (mount it against a wall that has one).
 */
export function Clock({
  position = [0, 0, 0],
  rotation = [0, 0, 0],
  radius = 0.55,
  timezone,
  frameColor,
  faceColor = '#0d1117',
  handColor = '#e8eef5',
  accentColor,
}: ClockProps) {
  const { unit, palette, time } = useWorld()
  const rim = frameColor ?? palette.metal
  const accent = accentColor ?? palette.accent
  const R = radius * unit
  const zone = timezone ?? time.timezone

  const fmt = useMemo(
    () =>
      zone
        ? new Intl.DateTimeFormat('en-GB', {
            timeZone: zone,
            hour: '2-digit',
            minute: '2-digit',
            second: '2-digit',
            hour12: false,
          })
        : null,
    [zone],
  )

  const ticks = useMemo(() => {
    const inset = R - 0.06 * unit
    return Array.from({ length: 12 }, (_, i) => {
      const a = (i / 12) * Math.PI * 2
      return { a, big: i % 3 === 0, x: Math.sin(a) * inset, y: Math.cos(a) * inset }
    })
  }, [R, unit])

  const hour = useRef<Group>(null)
  const minute = useRef<Group>(null)
  const second = useRef<Group>(null)

  useFrame(() => {
    const { h, m, s } = readTime(fmt)
    const TAU = Math.PI * 2
    // Clockwise, so negate; 12 o'clock is up (rotation 0).
    if (second.current) second.current.rotation.z = -(s / 60) * TAU
    if (minute.current) minute.current.rotation.z = -((m + s / 60) / 60) * TAU
    if (hour.current) hour.current.rotation.z = -(((h % 12) + m / 60) / 12) * TAU
  })

  return (
    <group position={position} rotation={rotation}>
      {/* rim + dial, circular faces turned to point +z */}
      <mesh rotation={[Math.PI / 2, 0, 0]} castShadow receiveShadow>
        <cylinderGeometry args={[R + 0.05 * unit, R + 0.05 * unit, 0.06 * unit, 48]} />
        <meshStandardMaterial color={rim} metalness={0.4} roughness={0.6} />
      </mesh>
      <mesh position={[0, 0, 0.031 * unit]} rotation={[Math.PI / 2, 0, 0]}>
        <cylinderGeometry args={[R, R, 0.02 * unit, 48]} />
        <meshStandardMaterial color={faceColor} roughness={0.5} />
      </mesh>

      {ticks.map(({ a, big, x, y }) => (
        <mesh key={a} position={[x, y, 0.045 * unit]} rotation={[0, 0, -a]}>
          <boxGeometry
            args={[(big ? 0.03 : 0.015) * unit, (big ? 0.1 : 0.06) * unit, 0.01 * unit]}
          />
          <meshStandardMaterial color={handColor} emissive={handColor} emissiveIntensity={0.2} />
        </mesh>
      ))}

      {/* hands: anchored at center, extending up; group rotation sweeps them */}
      <group ref={hour}>
        <mesh position={[0, R * 0.28, 0.05 * unit]}>
          <boxGeometry args={[0.035 * unit, R * 0.55, 0.012 * unit]} />
          <meshStandardMaterial color={handColor} emissive={handColor} emissiveIntensity={0.12} />
        </mesh>
      </group>
      <group ref={minute}>
        <mesh position={[0, R * 0.38, 0.055 * unit]}>
          <boxGeometry args={[0.022 * unit, R * 0.78, 0.012 * unit]} />
          <meshStandardMaterial color={handColor} emissive={handColor} emissiveIntensity={0.12} />
        </mesh>
      </group>
      <group ref={second}>
        <mesh position={[0, R * 0.42, 0.06 * unit]}>
          <boxGeometry args={[0.008 * unit, R * 0.86, 0.01 * unit]} />
          <meshStandardMaterial
            color={accent}
            emissive={accent}
            emissiveIntensity={0.6}
            toneMapped={false}
          />
        </mesh>
      </group>

      {/* center hub */}
      <mesh position={[0, 0, 0.066 * unit]} rotation={[Math.PI / 2, 0, 0]}>
        <cylinderGeometry args={[0.03 * unit, 0.03 * unit, 0.03 * unit, 16]} />
        <meshStandardMaterial
          color={accent}
          emissive={accent}
          emissiveIntensity={0.5}
          toneMapped={false}
        />
      </mesh>
    </group>
  )
}