Cookbook — Numbered step timeline w/ inline timers
A vertical, numbered cooking timeline where every instruction is a node on a connecting rail, complete with optional sage tip callouts and appetizing CSS-gradient dish photos. Durations mentioned in a step (simmer 15 min, rest 3 min) spawn an inline countdown button that ticks down in real time and toasts when it hits zero. Marking a step done dims and checks it, the rail and a progress bar fill as you cook, and a finish card greets you at the last step.
MCP
Code
:root {
--cream: #faf6ef;
--paper: #fffdf8;
--ink: #2b2622;
--ink-2: #5c534a;
--muted: #8a7f73;
--tomato: #d6452b;
--tomato-d: #b8351e;
--saffron: #e8a33d;
--sage: #7c8a6b;
--clay: #c8775a;
--line: rgba(43, 38, 34, 0.12);
--line-2: rgba(43, 38, 34, 0.2);
--ok: #3f8f5f;
--warn: #d98a2b;
--danger: #c8412b;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 22px;
--sh-1: 0 1px 2px rgba(43, 38, 34, 0.1);
--sh-2: 0 10px 30px rgba(43, 38, 34, 0.1);
--serif: "Fraunces", Georgia, serif;
--sans: "Inter", system-ui, -apple-system, sans-serif;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
font-family: var(--sans);
background: var(--cream);
color: var(--ink);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.page {
max-width: 760px;
margin: 0 auto;
padding: 40px 20px 64px;
}
/* ---------- Masthead ---------- */
.masthead {
text-align: center;
padding-bottom: 28px;
border-bottom: 1px solid var(--line);
margin-bottom: 32px;
}
.kicker {
font-family: var(--sans);
text-transform: uppercase;
letter-spacing: 0.18em;
font-size: 0.72rem;
font-weight: 600;
color: var(--tomato-d);
margin: 0 0 10px;
}
.title {
font-family: var(--serif);
font-weight: 600;
font-size: clamp(1.9rem, 6vw, 3rem);
line-height: 1.08;
margin: 0 0 14px;
color: var(--ink);
}
.dek {
font-family: var(--serif);
font-weight: 500;
font-size: clamp(1rem, 2.4vw, 1.18rem);
color: var(--ink-2);
max-width: 54ch;
margin: 0 auto 22px;
font-style: italic;
}
.meta {
list-style: none;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px 10px;
margin: 0;
padding: 0;
}
.meta li {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.84rem;
font-weight: 500;
color: var(--ink-2);
background: var(--paper);
border: 1px solid var(--line);
border-radius: 999px;
padding: 5px 12px;
box-shadow: var(--sh-1);
}
.meta-ico {
font-size: 0.95rem;
}
/* ---------- Panel ---------- */
.cook-panel {
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-2);
padding: 28px clamp(16px, 4vw, 32px) 32px;
}
.panel-head {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 14px;
margin-bottom: 18px;
}
.section-title {
font-family: var(--serif);
font-weight: 600;
font-size: 1.4rem;
margin: 0;
}
.progress-wrap {
min-width: 200px;
flex: 1 1 200px;
max-width: 320px;
}
.progress-label {
display: block;
font-size: 0.78rem;
font-weight: 600;
color: var(--muted);
text-align: right;
margin-bottom: 6px;
}
.progress-track {
height: 8px;
background: rgba(43, 38, 34, 0.08);
border-radius: 999px;
overflow: hidden;
}
.progress-fill {
height: 100%;
width: 0;
border-radius: 999px;
background: linear-gradient(90deg, var(--saffron), var(--tomato));
transition: width 0.5s cubic-bezier(0.22, 1, 0.36, 1);
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 26px;
}
/* ---------- Buttons ---------- */
.btn {
font-family: var(--sans);
font-size: 0.86rem;
font-weight: 600;
border-radius: var(--r-sm);
border: 1px solid transparent;
padding: 8px 14px;
cursor: pointer;
transition: background 0.18s ease, color 0.18s ease, border-color 0.18s ease,
transform 0.08s ease;
}
.btn:active {
transform: translateY(1px);
}
.btn:focus-visible {
outline: 2px solid var(--tomato);
outline-offset: 2px;
}
.btn-ghost {
background: transparent;
border-color: var(--line-2);
color: var(--ink-2);
}
.btn-ghost:hover {
background: rgba(43, 38, 34, 0.04);
color: var(--ink);
}
.btn-timer {
background: var(--saffron);
color: var(--ink);
border-color: rgba(43, 38, 34, 0.14);
display: inline-flex;
align-items: center;
gap: 7px;
box-shadow: var(--sh-1);
}
.btn-timer:hover {
background: #f0b052;
}
.btn-timer.running {
background: var(--tomato);
color: #fff;
}
.btn-timer.running:hover {
background: var(--tomato-d);
}
.btn-done {
background: var(--sage);
color: #fff;
border-color: rgba(43, 38, 34, 0.14);
box-shadow: var(--sh-1);
}
.btn-done:hover {
background: #6c7a5b;
}
.timer-clock {
font-variant-numeric: tabular-nums;
font-weight: 700;
letter-spacing: 0.02em;
}
/* ---------- Timeline ---------- */
.timeline {
list-style: none;
margin: 0;
padding: 0;
position: relative;
}
/* the connecting rail */
.timeline::before {
content: "";
position: absolute;
left: 17px;
top: 18px;
bottom: 18px;
width: 3px;
background: rgba(43, 38, 34, 0.1);
border-radius: 3px;
}
/* the fill overlay on the rail */
.timeline::after {
content: "";
position: absolute;
left: 17px;
top: 18px;
width: 3px;
height: var(--rail-fill, 0%);
background: linear-gradient(180deg, var(--saffron), var(--tomato));
border-radius: 3px;
transition: height 0.5s cubic-bezier(0.22, 1, 0.36, 1);
}
.step {
position: relative;
padding: 0 0 26px 56px;
}
.step:last-child {
padding-bottom: 0;
}
.badge {
position: absolute;
left: 0;
top: 0;
width: 37px;
height: 37px;
border-radius: 50%;
display: grid;
place-items: center;
font-family: var(--serif);
font-weight: 700;
font-size: 1.05rem;
color: var(--ink);
background: var(--paper);
border: 2px solid var(--line-2);
z-index: 1;
transition: background 0.25s ease, color 0.25s ease, border-color 0.25s ease,
transform 0.25s ease;
}
.step-body {
background: var(--cream);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 14px 16px 16px;
box-shadow: var(--sh-1);
transition: opacity 0.3s ease, border-color 0.25s ease;
}
.step-text {
margin: 0;
font-size: 1rem;
color: var(--ink);
}
.step-text .hl {
background: linear-gradient(transparent 62%, rgba(232, 163, 61, 0.45) 0);
font-weight: 600;
padding: 0 1px;
border-radius: 2px;
}
.tip {
margin: 12px 0 0;
display: flex;
gap: 9px;
font-size: 0.85rem;
line-height: 1.5;
color: var(--ink-2);
background: rgba(124, 138, 107, 0.12);
border-left: 3px solid var(--sage);
border-radius: 0 var(--r-sm) var(--r-sm) 0;
padding: 9px 12px;
}
.tip b {
color: var(--sage);
}
.tip-emoji {
font-size: 1rem;
line-height: 1.4;
}
.step-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 14px;
}
.timer-hint {
font-size: 0.78rem;
color: var(--muted);
}
/* completed state */
.step.done .badge {
background: var(--sage);
border-color: var(--sage);
color: #fff;
}
.step.done .badge .num {
display: none;
}
.step.done .badge .check {
display: block;
}
.badge .check {
display: none;
font-size: 1.2rem;
line-height: 1;
}
.step.done .step-body {
opacity: 0.6;
border-color: var(--line);
}
.step.done .step-text {
text-decoration: line-through;
text-decoration-color: var(--muted);
color: var(--ink-2);
}
.step.done .tip,
.step.done .timer-hint {
display: none;
}
/* photo accent — CSS gradient "plate" on alternating steps */
.plate {
margin: 14px 0 0;
height: 96px;
border-radius: var(--r-md);
position: relative;
overflow: hidden;
border: 1px solid var(--line);
display: grid;
place-items: center;
}
.plate::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(
120% 120% at 25% 20%,
rgba(255, 255, 255, 0.55),
transparent 45%
),
radial-gradient(80% 90% at 80% 110%, rgba(43, 38, 34, 0.22), transparent 60%);
}
.plate-tomato {
background: radial-gradient(circle at 35% 30%, #f2674a, var(--tomato) 55%, var(--tomato-d));
}
.plate-saffron {
background: radial-gradient(circle at 35% 30%, #f3c477, var(--saffron) 55%, #cf882a);
}
.plate-sage {
background: radial-gradient(circle at 35% 30%, #9fae8b, var(--sage) 55%, #5f6c4f);
}
.plate-clay {
background: radial-gradient(circle at 35% 30%, #e0997e, var(--clay) 55%, #a85d42);
}
.plate-emoji {
position: relative;
font-size: 2.4rem;
filter: drop-shadow(0 3px 5px rgba(0, 0, 0, 0.25));
}
.step.done .plate {
display: none;
}
/* ---------- Finish ---------- */
.finish-card {
margin-top: 24px;
text-align: center;
background: linear-gradient(160deg, rgba(232, 163, 61, 0.15), rgba(214, 69, 43, 0.12));
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 28px 20px;
animation: pop 0.4s ease;
}
@keyframes pop {
from {
transform: scale(0.96);
opacity: 0;
}
}
.finish-emoji {
font-size: 2.6rem;
}
.finish-card h3 {
font-family: var(--serif);
font-weight: 600;
font-size: 1.5rem;
margin: 8px 0 4px;
}
.finish-card p {
margin: 0;
color: var(--ink-2);
font-family: var(--serif);
font-style: italic;
}
/* ---------- Footer ---------- */
.foot {
text-align: center;
margin-top: 36px;
font-size: 0.8rem;
color: var(--muted);
}
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 24px);
background: var(--ink);
color: var(--paper);
font-size: 0.88rem;
font-weight: 500;
padding: 11px 18px;
border-radius: 999px;
box-shadow: var(--sh-2);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease, transform 0.25s ease;
z-index: 50;
max-width: calc(100vw - 32px);
}
.toast.show {
opacity: 1;
transform: translate(-50%, 0);
}
/* ---------- Responsive ---------- */
@media (max-width: 720px) {
.page {
padding: 28px 14px 48px;
}
.panel-head {
flex-direction: column;
align-items: stretch;
}
.progress-wrap {
max-width: none;
}
.progress-label {
text-align: left;
}
}
@media (max-width: 420px) {
.step {
padding-left: 48px;
}
.step-actions .btn {
flex: 1 1 auto;
justify-content: center;
}
}
@media (prefers-reduced-motion: reduce) {
* {
transition: none !important;
animation: none !important;
}
}(function () {
"use strict";
/* ---------- Recipe data (fictional) ---------- */
// text: instruction (use {{20 min}} markers to auto-detect a timer)
// tip: optional callout. plate: optional {emoji,kind} CSS "photo".
var STEPS = [
{
text: "Bring a wide, heavy skillet to medium-high and warm a generous glug of olive oil until it shimmers.",
tip: "A dry, hot pan is the secret to char — don't crowd it.",
plate: { emoji: "🫒", kind: "saffron" },
},
{
text: "Add 400g of halved cherry tomatoes cut-side down and let them sit, undisturbed, to blister for {{6 min}}.",
tip: "Resist stirring — you want deep, jammy char marks.",
plate: { emoji: "🍅", kind: "tomato" },
},
{
text: "Stir in 3 cloves of sliced garlic and a pinch of chili flakes; toast just until fragrant, about {{1 min}}.",
tip: "Garlic burns fast — keep it moving here.",
},
{
text: "Bloom a fat pinch of saffron threads in 60ml of warm stock, then pour it into the pan with 300g of orzo.",
plate: { emoji: "🌾", kind: "saffron" },
},
{
text: "Add 700ml of hot vegetable stock, season, cover, and simmer gently for {{15 min}}, stirring now and then.",
tip: "Stir along the bottom so the orzo never catches.",
},
{
text: "Uncover, fold through a knob of butter and a handful of grated pecorino, and rest off the heat for {{3 min}}.",
tip: "Resting lets the starch set into a silky, risotto-like body.",
plate: { emoji: "🧀", kind: "clay" },
},
{
text: "Finish with torn basil, a squeeze of lemon, and a final drizzle of oil. Plate and serve warm.",
tip: "Taste for salt one last time before it hits the table.",
plate: { emoji: "🌿", kind: "sage" },
},
];
var $ = function (sel, ctx) {
return (ctx || document).querySelector(sel);
};
/* ---------- Toast ---------- */
var toastEl = $("#toast");
var toastTimer = null;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("show");
}, 2600);
}
/* ---------- Build markup ---------- */
var timeline = $("#timeline");
var timers = []; // one entry per step: { remaining, intervalId, seconds } | null
function parseInstruction(raw) {
// returns { html, minutes|null }
var minutes = null;
var html = raw.replace(/\{\{\s*(\d+)\s*min\s*\}\}/g, function (_, m) {
minutes = parseInt(m, 10);
return '<span class="hl">' + m + " min</span>";
});
return { html: html, minutes: minutes };
}
function fmt(total) {
var m = Math.floor(total / 60);
var s = total % 60;
return (m < 10 ? "0" : "") + m + ":" + (s < 10 ? "0" : "") + s;
}
STEPS.forEach(function (step, i) {
var parsed = parseInstruction(step.text);
var li = document.createElement("li");
li.className = "step";
li.dataset.index = String(i);
var badge = document.createElement("div");
badge.className = "badge";
badge.setAttribute("aria-hidden", "true");
badge.innerHTML =
'<span class="num">' + (i + 1) + '</span><span class="check">✓</span>';
var body = document.createElement("div");
body.className = "step-body";
var p = document.createElement("p");
p.className = "step-text";
p.innerHTML = parsed.html;
body.appendChild(p);
if (step.plate) {
var plate = document.createElement("div");
plate.className = "plate plate-" + step.plate.kind;
plate.setAttribute("role", "img");
plate.setAttribute("aria-label", "Illustrative dish photo");
plate.innerHTML =
'<span class="plate-emoji" aria-hidden="true">' +
step.plate.emoji +
"</span>";
body.appendChild(plate);
}
if (step.tip) {
var tip = document.createElement("p");
tip.className = "tip";
tip.innerHTML =
'<span class="tip-emoji" aria-hidden="true">💡</span><span><b>Tip </b>' +
step.tip +
"</span>";
body.appendChild(tip);
}
var actions = document.createElement("div");
actions.className = "step-actions";
var doneBtn = document.createElement("button");
doneBtn.type = "button";
doneBtn.className = "btn btn-done";
doneBtn.textContent = "Mark done";
doneBtn.addEventListener("click", function () {
toggleDone(i);
});
actions.appendChild(doneBtn);
if (parsed.minutes !== null) {
var seconds = parsed.minutes * 60;
timers[i] = { remaining: seconds, seconds: seconds, intervalId: null };
var timerBtn = document.createElement("button");
timerBtn.type = "button";
timerBtn.className = "btn btn-timer";
timerBtn.setAttribute("aria-live", "polite");
timerBtn.innerHTML =
'<span aria-hidden="true">⏱️</span> Start <span class="timer-clock">' +
fmt(seconds) +
"</span>";
timerBtn.addEventListener("click", function () {
toggleTimer(i, timerBtn, parsed.minutes);
});
actions.appendChild(timerBtn);
} else {
timers[i] = null;
var hint = document.createElement("span");
hint.className = "timer-hint";
hint.textContent = "No timer for this step";
actions.appendChild(hint);
}
body.appendChild(actions);
li.appendChild(badge);
li.appendChild(body);
timeline.appendChild(li);
});
/* ---------- Done logic ---------- */
function toggleDone(i) {
var li = timeline.querySelector('.step[data-index="' + i + '"]');
var doneBtn = li.querySelector(".btn-done");
var isDone = li.classList.toggle("done");
doneBtn.textContent = isDone ? "Undo" : "Mark done";
if (isDone) {
// stop any running timer on a completed step
stopTimer(i);
}
updateProgress();
}
/* ---------- Timer logic ---------- */
function toggleTimer(i, btn, minutes) {
var t = timers[i];
if (!t) return;
if (t.intervalId) {
stopTimer(i);
return;
}
btn.classList.add("running");
tickLabel(i, btn);
t.intervalId = setInterval(function () {
t.remaining -= 1;
if (t.remaining <= 0) {
t.remaining = 0;
tickLabel(i, btn);
stopTimer(i);
ding(i, btn);
return;
}
tickLabel(i, btn);
}, 1000);
toast("Step " + (i + 1) + " timer started — " + minutes + ":00");
}
function stopTimer(i) {
var t = timers[i];
if (!t || !t.intervalId) return;
clearInterval(t.intervalId);
t.intervalId = null;
var li = timeline.querySelector('.step[data-index="' + i + '"]');
var btn = li && li.querySelector(".btn-timer");
if (btn) {
btn.classList.remove("running");
tickLabel(i, btn);
}
}
function tickLabel(i, btn) {
var t = timers[i];
var clock = '<span class="timer-clock">' + fmt(t.remaining) + "</span>";
var label;
if (t.intervalId) {
label = "Pause " + clock;
} else if (t.remaining === 0) {
label = "Done " + clock;
} else if (t.remaining < t.seconds) {
label = "Resume " + clock;
} else {
label = "Start " + clock;
}
btn.innerHTML = '<span aria-hidden="true">⏱️</span> ' + label;
}
function ding(i, btn) {
toast("⏰ Step " + (i + 1) + " timer is up!");
btn.classList.add("running");
var flashes = 0;
var flash = setInterval(function () {
btn.classList.toggle("running");
flashes += 1;
if (flashes >= 6) {
clearInterval(flash);
btn.classList.remove("running");
}
}, 320);
}
/* ---------- Progress + rail fill ---------- */
var progressFill = $("#progress-fill");
var progressText = $("#progress-text");
var finishCard = $("#finish-card");
var total = STEPS.length;
function updateProgress() {
var doneCount = timeline.querySelectorAll(".step.done").length;
var pct = total ? Math.round((doneCount / total) * 100) : 0;
progressFill.style.width = pct + "%";
timeline.style.setProperty("--rail-fill", pct + "%");
progressText.textContent = doneCount + " of " + total + " steps done";
finishCard.hidden = doneCount !== total;
if (doneCount === total && total > 0) {
toast("🍽️ Every step done — dinner is served!");
}
}
/* ---------- Toolbar ---------- */
$("#reset-all").addEventListener("click", function () {
timeline.querySelectorAll(".step").forEach(function (li) {
var i = parseInt(li.dataset.index, 10);
li.classList.remove("done");
var dBtn = li.querySelector(".btn-done");
if (dBtn) dBtn.textContent = "Mark done";
stopTimer(i);
var t = timers[i];
if (t) {
t.remaining = t.seconds;
var tBtn = li.querySelector(".btn-timer");
if (tBtn) tickLabel(i, tBtn);
}
});
updateProgress();
toast("Timeline reset — back to step 1.");
});
$("#stop-timers").addEventListener("click", function () {
var any = false;
timers.forEach(function (t, i) {
if (t && t.intervalId) {
stopTimer(i);
any = true;
}
});
toast(any ? "All timers stopped." : "No timers were running.");
});
/* ---------- init ---------- */
updateProgress();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Cookbook — Numbered Step Timeline</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600;9..144,700&family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="masthead" role="banner">
<p class="kicker">Weeknight Suppers · No. 14</p>
<h1 class="title">Charred Tomato & Saffron Orzo</h1>
<p class="dek">
A one-pan, sun-warmed orzo that simmers blistered tomatoes, sweet
saffron, and a fistful of garden herbs into something far greater than
its pantry parts. Follow the steps in order — the timers do the
watching for you.
</p>
<ul class="meta" aria-label="Recipe details">
<li><span class="meta-ico" aria-hidden="true">⏱️</span> 45 min total</li>
<li><span class="meta-ico" aria-hidden="true">🍽️</span> Serves 4</li>
<li><span class="meta-ico" aria-hidden="true">🌶️</span> Easy heat</li>
<li><span class="meta-ico" aria-hidden="true">🌿</span> Vegetarian</li>
</ul>
</header>
<main id="main" role="main">
<section class="cook-panel" aria-labelledby="cook-h">
<div class="panel-head">
<h2 id="cook-h" class="section-title">Let’s cook</h2>
<div class="progress-wrap" role="status" aria-live="polite">
<span class="progress-label" id="progress-text">0 of 7 steps done</span>
<div class="progress-track" aria-hidden="true">
<div class="progress-fill" id="progress-fill" style="width:0%"></div>
</div>
</div>
</div>
<div class="toolbar">
<button type="button" class="btn btn-ghost" id="reset-all">Reset all steps</button>
<button type="button" class="btn btn-ghost" id="stop-timers">Stop all timers</button>
</div>
<ol class="timeline" id="timeline" aria-label="Cooking steps"></ol>
<div class="finish-card" id="finish-card" hidden>
<span class="finish-emoji" aria-hidden="true">🍅</span>
<h3>Plated & done.</h3>
<p>Finish with torn basil, a drift of pecorino, and a squeeze of lemon. Buon appetito.</p>
</div>
</section>
</main>
<footer class="foot" role="contentinfo">
<p>Illustrative UI only — recipe & nutrition data are fictional, not dietary advice.</p>
</footer>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Numbered step timeline w/ inline timers
A warm, editorial recipe card rendered as a vertical timeline. Each step is a round number badge pinned to a connecting rail, paired with the instruction, an optional Tip callout, and an appetizing CSS-gradient “photo” plate built entirely from radial gradients and a single food emoji — no images or libraries. The serif Fraunces headings and cream paper surfaces give it the feel of a printed cookbook page.
The interactions do the watching for you. Any step that mentions a duration (simmer 15 min, rest 3 min) gets an inline timer button: tap to start a live countdown, tap again to pause or resume, and a toast fires the moment it reaches zero. Marking a step done adds a check to its badge, dims and strikes through the body, and stops any timer still running on it. As you complete steps, both the connecting rail and the header progress bar fill from saffron to tomato, and a celebratory finish card appears once the last step is checked.
Toolbar buttons stop every running timer or reset the whole timeline back to the start. The layout is single-column and responsive from 360px up, controls are keyboard-usable with visible focus rings, and progress and timer updates are announced via aria-live regions.
Illustrative UI only — recipes & nutrition data are fictional, not dietary advice.