<time data-controller="time-ago" datetime="<%= post.created_at.iso8601 %>">
<%= post.created_at.to_fs(:short) %>
</time>
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
const dt = new Date(this.element.getAttribute("datetime"))
const seconds = Math.round((Date.now() - dt.getTime()) / 1000)
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" })
if (seconds < 60) return (this.element.textContent = rtf.format(-seconds, "second"))
const minutes = Math.round(seconds / 60)
if (minutes < 60) return (this.element.textContent = rtf.format(-minutes, "minute"))
const hours = Math.round(minutes / 60)
if (hours < 24) return (this.element.textContent = rtf.format(-hours, "hour"))
const days = Math.round(hours / 24)
this.element.textContent = rtf.format(-days, "day")
}
}
For small UX touches like “3 minutes ago”, I don’t want to pull in a giant date library. A Stimulus controller can use Intl.RelativeTimeFormat plus a lightweight difference calculation. The server renders the ISO timestamp (via time_tag), and the controller turns it into a relative string on connect. It stays correct across Turbo page changes because the controller reconnects. For longer-lived pages, you can also update on an interval. The main thing is to keep a fallback for no-JS: the raw timestamp is still in the HTML and accessible. This pattern is also helpful for localized UIs since Intl handles language and pluralization.