How I Added a Live Runtime Widget to My Astro Blog

Adding a runtime counter that tracks how long your site has been alive is a surprisingly nice way to show visitors that the blog keeps evolving. Below is the exact recipe I followed to wire up a Beijing-time runtime ticker in an Astro + Yukina stack—you can reuse it for any Astro site with minor tweaks.

Prerequisites

  • Astro 4+ (this repo runs 5.3) with TypeScript enabled
  • Tailwind utilities available via @astrojs/tailwind
  • A shared layout/footer component rendered on every page (mine lives in src/components/Footer.astro)
  • The baseline timestamp you want to track, e.g., October 8, 2025 at 8:00 AM Beijing time

Step 1 · Build a Runtime Component

Create src/components/RuntimeTicker.astro and drop in the following code:

---
const startDateIso = "2025-10-08T08:00:00+08:00";
const runtimeId = `runtime-counter-${Astro.componentInstance}`;
const readableStart = "Oct 8, 2025 · 8:00 AM";
---

<div
  id={runtimeId}
  data-start={startDateIso}
  class="runtime-card mt-6 w-full max-w-md rounded-2xl border border-[var(--primary-color-lighten)]/40 bg-[var(--card-color)] p-4 text-center text-sm text-[var(--text-color-lighten)]"
>
  <p class="uppercase tracking-wide">Runtime since {readableStart}</p>
  <p class="runtime-value mt-1 text-lg font-semibold text-[var(--text-color)]" data-runtime>
    0 y 0 m 0 d 0 h
  </p>
</div>

<script is:inline define:vars={{ runtimeId }}>
  (() => {
    const container = document.getElementById(runtimeId);
    const target = container?.querySelector("[data-runtime]");
    const startIso = container?.dataset.start ?? null;
    const startTime = startIso ? new Date(startIso) : null;
    if (!container || !target || !startTime || Number.isNaN(startTime.valueOf())) return;

    const formatter = new Intl.DateTimeFormat("en-CA", {
      timeZone: "Asia/Shanghai",
      hour12: false,
      year: "numeric",
      month: "2-digit",
      day: "2-digit",
      hour: "2-digit",
      minute: "2-digit",
      second: "2-digit",
    });

    const toParts = (date) => {
      const base = { year: 0, month: 0, day: 0, hour: 0, minute: 0, second: 0 };
      formatter.formatToParts(date).forEach((part) => {
        if (part.type !== "literal" && part.type in base) base[part.type] = Number(part.value);
      });
      return base;
    };

    const daysInMonth = (year, month) => new Date(year, month, 0).getDate();
    const diffCalendar = (startParts, endParts) => {
      const current = { ...endParts };
      const start = { ...startParts };
      if (current.second < start.second) { current.second += 60; current.minute -= 1; }
      if (current.minute < start.minute) { current.minute += 60; current.hour -= 1; }
      if (current.hour < start.hour) { current.hour += 24; current.day -= 1; }
      if (current.day < start.day) {
        current.month -= 1;
        if (current.month < 1) { current.month = 12; current.year -= 1; }
        current.day += daysInMonth(current.year, current.month);
      }
      if (current.month < start.month) { current.month += 12; current.year -= 1; }
      return {
        years: Math.max(0, current.year - start.year),
        months: Math.max(0, current.month - start.month),
        days: Math.max(0, current.day - start.day),
        hours: Math.max(0, current.hour - start.hour),
      };
    };

    const startParts = toParts(startTime);
    const render = () => {
      if (Date.now() <= startTime.getTime()) {
        target.textContent = "0 y 0 m 0 d 0 h";
        return;
      }
      const diff = diffCalendar(startParts, toParts(new Date()));
      target.textContent = `${diff.years} y ${diff.months} m ${diff.days} d ${diff.hours} h`;
    };

    render();
    const timer = window.setInterval(render, 60 * 1000);
    document.addEventListener("astro:before-swap", () => window.clearInterval(timer), { once: true });
  })();
</script>

Highlights:

  • The component renders a styled card with a placeholder value.
  • The inline script converts both the fixed start timestamp and the browser’s real-time clock into Asia/Shanghai calendar parts using Intl.DateTimeFormat, so Beijing time stays accurate regardless of the visitor’s locale.
  • A manual borrow algorithm ensures the Y/M/D/H breakdown respects calendar boundaries, e.g., month lengths and leap years.

Step 2 · Mount the Widget Everywhere

Import and render RuntimeTicker inside your global footer or layout after existing copyright.

---
import RuntimeTicker from "./RuntimeTicker.astro";
---

<footer>
  <!-- existing footer markup -->
  <RuntimeTicker />
</footer>

Placing it at the bottom keeps the ticker visible without interrupting your article flow.

Step 3 · Verify Locally

Run the dev server and ensure the counter increments in real time.

pnpm dev

Steps I follow during QA:

  1. Load any page and scroll to the footer; the widget should show a non-zero value (unless you set a future start date).
  2. Keep the tab open for a couple of minutes—the hour count should bump exactly when the Beijing-time difference crosses another hour.
  3. Navigate between routes; thanks to the astro:before-swap listener, you should not see duplicated intervals.

Step 4 · Ship with Confidence

Before merging, make sure production builds succeed:

pnpm build && pnpm astro check

Once deployed, the ticker requires no backend calls—it runs purely on the client and depends only on the visitor’s clock plus the Asia/Shanghai conversion, so it works even on static hosting. If you ever need a new start date, change startDateIso and the readableStart string together to keep the UI honest.

Happy blogging, and enjoy watching your site’s runtime grow! 🚀

Live Runtime Widget

Author

Shayne Wong

Publish Date

11 - 13 - 2025

License

Shayne Wong

Avatar
Shayne Wong

All time is no time when it is past.