Layr Components

Components / Navigation

Timecode

A running HH:MM:SS:FF timecode for the navigation that plays forward as the page scrolls, with the page's total runtime beside it.

Scroll insideFull screen
Props
24
120
12 px
#0E0E0D
#6E6E6B

Scroll inside the preview and watch the bar at the top.

In Framer, open Assets, add a new code file, paste this over everything in it and save. Timecode shows up in the Insert menu, and its controls appear in the right panel.
// Timecode · Layr Components
//
// A running timecode, HH:MM:SS:FF, that treats the page like a film: scrolling
// down plays it forward and scrolling up winds it back. The page's total
// runtime can sit beside it.
// Paste into a new code file in Framer. Place it in the navigation; it sizes to its text.

import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { useEffect, useRef, useState, type CSSProperties } from "react"

interface TimecodeProps {
    label: string
    showTotal: boolean
    fps: number
    pixelsPerSecond: number
    fontFamily: string
    numeralFamily: string
    fontSize: number
    color: string
    mutedColor: string
    style?: CSSProperties
}

const MONO = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"
const pad = (value: number) => String(Math.floor(value)).padStart(2, "0")

/** Seconds → HH:MM:SS:FF, the way an edit timeline shows time. */
function toTimecode(seconds: number, fps: number) {
    const safe = Math.max(0, seconds)
    const hours = safe / 3600
    const minutes = (safe % 3600) / 60
    const secs = safe % 60
    const frames = (safe - Math.floor(safe)) * fps
    return `${pad(hours)}:${pad(minutes)}:${pad(secs)}:${pad(frames)}`
}

/**
 * @framerIntrinsicWidth 220
 * @framerIntrinsicHeight 16
 *
 * @framerSupportedLayoutWidth auto
 * @framerSupportedLayoutHeight auto
 */
export default function Timecode(props: TimecodeProps) {
    const {
        label = "Time",
        showTotal = true,
        fps = 24,
        pixelsPerSecond = 120,
        fontFamily = "Inter",
        numeralFamily = "",
        fontSize = 12,
        color = "#F2EFE9",
        mutedColor = "#8F8C86",
    } = props
    const isStatic = useIsStaticRenderer()
    const currentRef = useRef<HTMLSpanElement>(null)
    const [total, setTotal] = useState(toTimecode(144, fps))

    useEffect(() => {
        if (isStatic) return
        let frame = 0
        const update = () => {
            frame = 0
            const max = Math.max(0, document.documentElement.scrollHeight - window.innerHeight)
            if (currentRef.current) currentRef.current.textContent = toTimecode(window.scrollY / pixelsPerSecond, fps)
            const next = toTimecode(max / pixelsPerSecond, fps)
            setTotal((previous) => (previous === next ? previous : next))
        }
        const schedule = () => {
            if (!frame) frame = requestAnimationFrame(update)
        }
        update()
        window.addEventListener("scroll", schedule, { passive: true })
        window.addEventListener("resize", schedule)
        const observer = new ResizeObserver(schedule)
        observer.observe(document.body)
        return () => {
            if (frame) cancelAnimationFrame(frame)
            window.removeEventListener("scroll", schedule)
            window.removeEventListener("resize", schedule)
            observer.disconnect()
        }
    }, [isStatic, fps, pixelsPerSecond])

    const numerals: CSSProperties = { fontFamily: numeralFamily ? `"${numeralFamily}", ${MONO}` : MONO, letterSpacing: "0.04em" }

    return (
        <div
            style={{
                ...props.style,
                display: "flex",
                alignItems: "center",
                gap: "0.6em",
                whiteSpace: "nowrap",
                fontVariantNumeric: "tabular-nums",
                userSelect: "none",
                fontFamily: `"${fontFamily}", "Inter", system-ui, sans-serif`,
                fontSize,
                lineHeight: 1,
                color,
            }}
        >
            {label && <span style={{ color: mutedColor }}>{label}</span>}
            <span ref={currentRef} style={numerals}>
                {toTimecode(isStatic ? 12.34 : 0, fps)}
            </span>
            {showTotal && <span style={{ ...numerals, color: mutedColor }}>/ {total}</span>}
        </div>
    )
}

addPropertyControls(Timecode, {
    label: { type: ControlType.String, title: "Label", defaultValue: "Time" },
    showTotal: { type: ControlType.Boolean, title: "Runtime", defaultValue: true, enabledTitle: "Show", disabledTitle: "Hide" },
    fps: { type: ControlType.Number, title: "FPS", defaultValue: 24, min: 12, max: 60, step: 1 },
    pixelsPerSecond: {
        type: ControlType.Number,
        title: "Px / Second",
        defaultValue: 120,
        min: 20,
        max: 1000,
        step: 10,
        description: "How many pixels of scrolling count as one second of film.",
    },
    fontFamily: { type: ControlType.String, title: "Font family", defaultValue: "Inter", description: "For the label. Use the family name of your text styles." },
    numeralFamily: {
        type: ControlType.String,
        title: "Numeral font",
        defaultValue: "",
        placeholder: "System monospace",
        description: "For the numbers. Leave empty for the system monospace, so the digits never jump.",
    },
    fontSize: { type: ControlType.Number, title: "Size", defaultValue: 12, min: 9, max: 40, step: 1, unit: "px" },
    color: { type: ControlType.Color, title: "Color", defaultValue: "#F2EFE9" },
    mutedColor: { type: ControlType.Color, title: "Muted", defaultValue: "#8F8C86" },
})

In this preview

The photos are demo pictures shown on this site only; they are not in the files you copy. Set your own through the image props.