Timeline
Things that happened, in order, joined by a line: an audit log, an order's history, a deploy log, an activity feed, a roadmap. An ordered list with no script.
Dots and times
The common case. time: goes through local_time_tag, so time_format: :relative keeps itself current.
- Invoice created
- Sent to billing@acme.test
- Viewed by the customer
- Paid in full
<%= timeline label: "Invoice history", class: "w-full max-w-md" do |timeline| %>
<% timeline.event "Invoice created", time: @now - 3.days, time_format: :relative %>
<% timeline.event "Sent to billing@acme.test", time: @now - 2.days, time_format: :relative %>
<% timeline.event "Viewed by the customer", time: @now - 5.hours, time_format: :relative %>
<% timeline.event "Paid in full", time: @now - 12.minutes, time_format: :relative, tone: :good %>
<% end %>Order status
icon: in a circle, tone: to colour it, and pending: true for what hasn't happened yet: a hollow marker and a dashed line into it. A string time prints as it is.
- Order placed
3 items, $148.00
- Payment confirmed
Visa ending 4242
- Packed
Tracking 1Z 999 AA1 0123 4567 84
- Out for deliveryTomorrow(upcoming)
- DeliveredBy Friday(upcoming)
<%= timeline label: "Order #1042", class: "w-full max-w-md" do |timeline| %>
<% timeline.event "Order placed", time: @now - 2.days, icon: :shopping_cart,
description: "3 items, $148.00" %>
<% timeline.event "Payment confirmed", time: @now - 2.days + 4.minutes, icon: :credit_card, tone: :good,
description: "Visa ending 4242" %>
<% timeline.event "Packed", time: @now - 1.day, icon: :package_check %>
<% timeline.event "Shipped", time: @now - 20.hours, icon: :truck, tone: :info, href: "#",
description: "Tracking 1Z 999 AA1 0123 4567 84" %>
<% timeline.event "Out for delivery", time: "Tomorrow", icon: :truck, pending: true %>
<% timeline.event "Delivered", time: "By Friday", icon: :house, pending: true %>
<% end %>Activity feed
avatar: takes a name (or avatar options) and draws it small. The block is the event's body.
Activity
- Ada Lovelace opened the issue
- Grace Hopper commentedI can reproduce this on Safari 18. It only happens when the invoice has more than one currency.
- Katherine Johnson added a labelbug billing
Round each currency on its own #218
<%= card title: "Activity", class: "w-full max-w-lg" do %>
<%= timeline label: "Activity" do |timeline| %>
<% timeline.event "Ada Lovelace opened the issue", time: @now - 2.days, time_format: :relative,
avatar: "Ada Lovelace" %>
<% timeline.event "Grace Hopper commented", time: @now - 1.day, time_format: :relative,
avatar: "Grace Hopper" do %>
<div class="rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-2 dark:border-neutral-800 dark:bg-neutral-800/50">
I can reproduce this on Safari 18. It only happens when the invoice has more than one currency.
</div>
<% end %>
<% timeline.event "Katherine Johnson added a label", time: @now - 6.hours, time_format: :relative,
avatar: "Katherine Johnson" do %>
<%= badge "bug", tone: :bad %> <%= badge "billing" %>
<% end %>
<% timeline.event "Ada Lovelace linked a pull request", time: @now - 40.minutes, time_format: :relative,
icon: :git_pull_request, tone: :info, href: "#", description: "Round each currency on its own #218" %>
<% end %>
<% end %>Deploy log
A failed step carries its log in the body; anything goes there, a code view included.
- Build started
main at a1b2c3d
- Tests passed
1,284 examples in 2m 12s
- Migrations failed
== 20260922101500 AddCurrencyToInvoices: migrating ====== -- add_column(:invoices, :currency, :string, {null: false}) rails aborted! StandardError: PG::NotNullViolation: column "currency" contains null values - Rolled back
Production is still on 9f8e7d6
- Deploy to production(upcoming)
<%= timeline label: "Deploy #312", class: "w-full max-w-xl" do |timeline| %>
<% timeline.event "Build started", time: @now - 9.minutes, time_format: :time, icon: :git_commit_horizontal,
description: "main at a1b2c3d" %>
<% timeline.event "Tests passed", time: @now - 7.minutes, time_format: :time, icon: :circle_check, tone: :good,
description: "1,284 examples in 2m 12s" %>
<% timeline.event "Migrations failed", time: @now - 6.minutes, time_format: :time, icon: :circle_x, tone: :bad do %>
<%= code_view copy: false, max_height: "10rem" do %>
== 20260922101500 AddCurrencyToInvoices: migrating ======
-- add_column(:invoices, :currency, :string, {null: false})
rails aborted!
StandardError: PG::NotNullViolation: column "currency" contains null values
<% end %>
<% end %>
<% timeline.event "Rolled back", time: @now - 5.minutes, time_format: :time, icon: :rotate_cw, tone: :warn,
description: "Production is still on 9f8e7d6" %>
<% timeline.event "Deploy to production", icon: :rocket, pending: true %>
<% end %>Commits
href: links the title. Built from a loop, in the order given; the gem doesn't sort.
- Ada Lovelace ·
e41f0a2 - Grace Hopper ·
a1b2c3d - Katherine Johnson ·
7c9d1e0 - Katherine Johnson ·
3b5a8f4
<%= timeline label: "Commits on main", class: "w-full max-w-lg" do |timeline| %>
<% [
[ "Round each currency on its own", "e41f0a2", "Ada Lovelace", 25.minutes ],
[ "Add currency to invoices", "a1b2c3d", "Grace Hopper", 3.hours ],
[ "Merge pull request #214 from billing/tax-lines", "7c9d1e0", "Katherine Johnson", 1.day ],
[ "Extract TaxLine from Invoice", "3b5a8f4", "Katherine Johnson", 2.days ]
].each do |message, sha, author, ago| %>
<% timeline.event message, href: "#", time: @now - ago, time_format: :relative,
icon: (message.start_with?("Merge") ? :git_merge : :git_commit_horizontal) do %>
<span class="inline-flex items-center gap-2 text-xs text-neutral-500 dark:text-neutral-400">
<%= avatar author, size: :small %> <%= author %> · <code class="font-mono"><%= sha %></code>
</span>
<% end %>
<% end %>
<% end %>Roadmap, horizontal
orientation: :horizontal lays events side by side from 40rem, and falls back to vertical on a phone.
- Private betaQ1
40 teams onboarded
- Public launchQ2
Pricing and self-serve sign-up
- IntegrationsQ3
GitHub and Slack, in progress
- EnterpriseQ4(upcoming)
SSO, audit log, SLAs
<%= timeline label: "Roadmap", orientation: :horizontal, class: "w-full" do |timeline| %>
<% timeline.event "Private beta", time: "Q1", icon: :flag, tone: :good, description: "40 teams onboarded" %>
<% timeline.event "Public launch", time: "Q2", icon: :rocket, tone: :good, description: "Pricing and self-serve sign-up" %>
<% timeline.event "Integrations", time: "Q3", icon: :git_branch, tone: :info, description: "GitHub and Slack, in progress" %>
<% timeline.event "Enterprise", time: "Q4", icon: :milestone, pending: true, description: "SSO, audit log, SLAs" %>
<% end %>Releases, horizontal with dates
A Date reads as a date. Dots keep a horizontal timeline light.
<%= timeline label: "Releases", orientation: :horizontal, class: "w-full" do |timeline| %>
<% timeline.event "v0.4", time: Date.new(2026, 3, 2), href: "#" %>
<% timeline.event "v0.5", time: Date.new(2026, 5, 18), href: "#" %>
<% timeline.event "v0.6", time: Date.new(2026, 8, 4), href: "#" %>
<% timeline.event "v0.7", time: Date.new(2026, 9, 22), href: "#", tone: :good %>
<% timeline.event "v1.0", time: "Soon", pending: true %>
<% end %>Tones
Every tone on a dot and on an icon. Markers are decoration: the title says what happened.
- Neutral
Something happened
- Good
It worked
- Info
Worth knowing
- Warn
Look at this soon
- Bad
It failed
- Pending(upcoming)
Not yet
- Neutral
Edited
- Good
Approved
- Info
Mentioned
- Warn
Retried
- Bad
Rejected
- Pending(upcoming)
Scheduled
<div class="grid w-full gap-8 sm:grid-cols-2">
<%= timeline label: "Dots in each tone" do |timeline| %>
<% timeline.event "Neutral", description: "Something happened" %>
<% timeline.event "Good", tone: :good, description: "It worked" %>
<% timeline.event "Info", tone: :info, description: "Worth knowing" %>
<% timeline.event "Warn", tone: :warn, description: "Look at this soon" %>
<% timeline.event "Bad", tone: :bad, description: "It failed" %>
<% timeline.event "Pending", pending: true, description: "Not yet" %>
<% end %>
<%= timeline label: "Icons in each tone" do |timeline| %>
<% timeline.event "Neutral", icon: :pencil, description: "Edited" %>
<% timeline.event "Good", icon: :circle_check, tone: :good, description: "Approved" %>
<% timeline.event "Info", icon: :info, tone: :info, description: "Mentioned" %>
<% timeline.event "Warn", icon: :triangle_alert, tone: :warn, description: "Retried" %>
<% timeline.event "Bad", icon: :circle_x, tone: :bad, description: "Rejected" %>
<% timeline.event "Pending", icon: :clock, pending: true, description: "Scheduled" %>
<% end %>
</div>Mixed markers
Dots, icons and avatars share one column, so the line stays straight.
- Account created
- Ada Lovelace joined
- Plan changed to Pro
- Grace Hopper joined
- Password policy tightened
- Renews
(upcoming)
<%= timeline label: "Account history", class: "w-full max-w-md" do |timeline| %>
<% timeline.event "Account created", time: Date.new(2025, 11, 3), icon: :user_plus %>
<% timeline.event "Ada Lovelace joined", time: Date.new(2025, 11, 4), avatar: "Ada Lovelace" %>
<% timeline.event "Plan changed to Pro", time: Date.new(2026, 1, 12) %>
<% timeline.event "Grace Hopper joined", time: Date.new(2026, 2, 20), avatar: "Grace Hopper" %>
<% timeline.event "Password policy tightened", time: Date.new(2026, 6, 1), icon: :shield_alert, tone: :warn %>
<% timeline.event "Renews", time: Date.new(2026, 11, 3), pending: true %>
<% end %>Interview process
marker: :decimal puts each step's position in its marker, named as CSS's list-style-type. The time can be markup, a badge here, and the body holds cards, people and links.
Interview process
- Intro call with Ada 30 mins
A relaxed video call about how you work. Bring a story or two about a project you pushed over the line, and as many questions about us as you like.
Read the plan before you meet Ada · Why invoices are hard · 4:12 · 3 min 40 - Technical chat with Grace 1 hour
- Meet the founders 1 hr 30 mins
In person with Ada and Katherine, the other co-founder. We'll go through your experience in detail and share what you'd be working on in your first six months.
Your first six months · Where the product goes next · 11:05 · 4 min 12
<%
person = lambda do |name, role|
card class: "w-fit", flush: true do
item title: name, description: role do |item|
item.media { avatar name, size: :large, shape: :square }
item.actions { button "#{name}'s profile", :icon, icon: :external_link, href: "#" }
end
end
end
video = lambda do |title, series, timestamp, length|
link_to "#", class: "flex w-fit max-w-full items-center gap-3 rounded-xl border border-neutral-200 bg-neutral-50 p-1.5 pr-4 text-sm no-underline hover:bg-neutral-100 dark:border-neutral-800 dark:bg-neutral-800/50 dark:hover:bg-neutral-800" do
safe_join [
tag.span(class: "flex h-10 w-16 shrink-0 items-center justify-center rounded-lg bg-neutral-900 text-white dark:bg-neutral-700") do
unmagic_icon "unmagic_components:lucide/play", class: "size-4"
end,
tag.span(class: "min-w-0 text-neutral-600 dark:text-neutral-400") do
safe_join [
tag.strong(title, class: "font-semibold text-neutral-900 dark:text-neutral-100"), " · ", series, " · ",
tag.span(timestamp, class: "font-mono text-green-700 dark:text-green-400"), " · ", tag.span(length, class: "whitespace-nowrap")
]
end
]
end
end
duration = ->(text) { badge(safe_join([ unmagic_icon("unmagic_components:lucide/clock", class: "size-3"), text ], " "), tone: :good) }
%>
<div class="w-full max-w-3xl">
<h3 class="mb-4 text-lg font-semibold text-neutral-900 dark:text-neutral-100">Interview process</h3>
<%= timeline label: "Interview process", marker: :decimal do |timeline| %>
<% timeline.event "Intro call with Ada", time: duration.("30 mins"), tone: :good do %>
<div class="flex flex-col items-start gap-3">
<p class="m-0">
A relaxed video call about how you work. Bring a story or two about a project you pushed over the line,
and as many questions about us as you like.
</p>
<%= person.("Ada Lovelace", "Co-founder") %>
<%= video.("Read the plan before you meet Ada", "Why invoices are hard", "4:12", "3 min 40") %>
</div>
<% end %>
<% timeline.event "Technical chat with Grace", time: duration.("1 hour"), tone: :good do %>
<div class="flex flex-col items-start gap-3">
<p class="m-0">
Grace advises our engineering team while we hire a head of engineering. Expect to talk through a system
you've built, the trade-offs you made, and how you use AI tools day to day.
</p>
<p class="m-0">Across time zones, this is usually a morning in London and an afternoon in Toronto.</p>
<%= person.("Grace Hopper", "Engineering advisor") %>
</div>
<% end %>
<% timeline.event "Meet the founders", time: duration.("1 hr 30 mins"), tone: :good do %>
<div class="flex flex-col items-start gap-3">
<p class="m-0">
In person with Ada and Katherine, the other co-founder. We'll go through your experience in detail and
share what you'd be working on in your first six months.
</p>
<div class="flex flex-wrap gap-3">
<%= person.("Katherine Johnson", "Co-founder") %>
<%= person.("Ada Lovelace", "Co-founder") %>
</div>
<%= video.("Your first six months", "Where the product goes next", "11:05", "4 min 12") %>
</div>
<% end %>
<% end %>
</div>Actions in the body
A pending step can carry the buttons that move it along.
- Submitted by Katherine Johnson
Conference travel, $1,240.00
- Approved by the team lead
- Waiting for finance(upcoming)
<%= timeline label: "Expense #88", class: "w-full max-w-md" do |timeline| %>
<% timeline.event "Submitted by Katherine Johnson", time: @now - 1.day, time_format: :relative,
avatar: "Katherine Johnson", description: "Conference travel, $1,240.00" %>
<% timeline.event "Approved by the team lead", time: @now - 20.hours, time_format: :relative,
icon: :circle_check, tone: :good %>
<% timeline.event "Waiting for finance", icon: :clock, pending: true do %>
<div class="flex flex-wrap gap-2">
<%= button "Approve", :primary, size: :small %>
<%= button "Ask a question", size: :small %>
</div>
<% end %>
<% end %>Long titles
Titles and descriptions wrap; the time drops under the title when there's no room.
- Changed the webhook URL for the production environment to a new endpoint
https://hooks.acme.test/unmagic/production/incoming
- Rotated the signing secret
<div class="w-64">
<%= timeline label: "Audit log" do |timeline| %>
<% timeline.event "Changed the webhook URL for the production environment to a new endpoint",
time: @now - 2.hours, icon: :pencil,
description: "https://hooks.acme.test/unmagic/production/incoming" %>
<% timeline.event "Rotated the signing secret", time: @now - 1.hour, icon: :key_round %>
<% end %>
</div>Loading and empty
skeleton: true stands three placeholder events in. With no events a timeline renders nothing, so .presence || empty_state fills the gap.
No deploys yet
<div class="grid w-full gap-8 sm:grid-cols-2">
<%= timeline skeleton: true %>
<%= timeline(label: "Deploys") { nil }.presence ||
empty_state("Deploys show up here once you push to main.", title: "No deploys yet", icon: :rocket) %>
</div>