UI Components Medium
Chef's Daily Special Card
Rotating daily-special card with availability counter, countdown timer to end-of-service, allergen chips, and an 'add to order' CTA — swaps to a sold-out state when count hits zero.
Open in Lab
MCP
html css vanilla-js
Targets: JS HTML
Code
/* ── Phase 27 Restaurant Theme — Chef's Daily Special Card ── */
:root {
--cream: #FAF7F1;
--ink: #2C1A0E;
--forest: #345F40;
--forest-d: #213D29;
--terracotta: #C4622D;
--gold: #D4A853;
--warm-gray: #8A7D72;
--bone: #F0EBE0;
--radius-card: 16px;
--radius-chip: 999px;
--radius-btn: 14px;
--radius-dot: 50%;
--shadow-card: 0 8px 32px rgba(44, 26, 14, 0.12), 0 2px 8px rgba(44, 26, 14, 0.08);
--transition: 180ms ease;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', system-ui, sans-serif;
background: var(--bone);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 32px 16px;
color: var(--ink);
}
/* ── Page wrapper ── */
.page-wrapper {
width: 100%;
max-width: 480px;
}
/* ── Card ── */
.card {
position: relative;
background: var(--cream);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
overflow: hidden;
padding: 0 0 28px;
}
/* ── Sold-out overlay ── */
.sold-out-overlay {
position: absolute;
inset: 0;
background: rgba(44, 26, 14, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
opacity: 0;
pointer-events: none;
transition: opacity 320ms ease;
border-radius: var(--radius-card);
}
.sold-out-overlay.visible {
opacity: 1;
pointer-events: auto;
}
.sold-out-stamp {
font-family: 'Inter', sans-serif;
font-weight: 700;
font-size: 2rem;
letter-spacing: 0.12em;
color: #e85c5c;
border: 4px double #e85c5c;
padding: 12px 28px;
border-radius: 6px;
transform: rotate(-15deg);
background: rgba(250, 247, 241, 0.92);
box-shadow: 0 4px 20px rgba(0,0,0,0.25);
user-select: none;
}
/* ── Eyebrow ── */
.eyebrow {
font-family: 'Inter', sans-serif;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--terracotta);
padding: 20px 24px 12px;
}
/* ── Photo placeholder ── */
.photo-placeholder {
width: 100%;
aspect-ratio: 16 / 9;
background: linear-gradient(135deg, var(--terracotta) 0%, var(--forest) 100%);
display: flex;
align-items: center;
justify-content: center;
}
.photo-emoji {
font-size: 4.5rem;
filter: drop-shadow(0 4px 12px rgba(0,0,0,0.3));
animation: float 3.6s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
/* ── Chef badge ── */
.chef-badge {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 24px 4px;
}
.chef-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--forest);
color: var(--cream);
font-family: 'Inter', sans-serif;
font-size: 0.65rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
letter-spacing: 0.03em;
flex-shrink: 0;
border: 2px solid var(--bone);
}
.chef-label {
font-size: 0.8rem;
font-weight: 600;
color: var(--warm-gray);
}
/* ── Dish info ── */
.dish-info {
padding: 8px 24px 0;
}
.dish-title {
font-family: 'Playfair Display', Georgia, serif;
font-size: 1.75rem;
font-weight: 800;
color: var(--ink);
line-height: 1.2;
}
.dish-subtitle {
font-size: 0.82rem;
font-weight: 500;
color: var(--warm-gray);
margin-top: 4px;
}
/* ── Tasting notes ── */
.tasting-notes {
font-size: 0.88rem;
font-weight: 400;
font-style: italic;
color: var(--warm-gray);
line-height: 1.65;
padding: 12px 24px 0;
}
/* ── Allergen chips ── */
.allergen-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 14px 24px 0;
}
.allergen-chip {
background: var(--bone);
color: var(--ink);
font-size: 0.72rem;
font-weight: 500;
border-radius: var(--radius-chip);
padding: 3px 10px;
border: 1px solid rgba(44, 26, 14, 0.12);
letter-spacing: 0.01em;
}
/* ── Divider ── */
.divider {
height: 1px;
background: rgba(44, 26, 14, 0.08);
margin: 20px 24px 0;
}
/* ── Availability block ── */
.availability-block {
padding: 16px 24px 0;
}
.availability-header {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 10px;
}
.availability-label {
font-size: 0.85rem;
font-weight: 600;
color: var(--ink);
transition: color var(--transition);
}
.availability-label.low {
color: var(--terracotta);
}
.price {
font-family: 'Playfair Display', Georgia, serif;
font-size: 1.5rem;
font-weight: 700;
color: var(--gold);
}
/* ── Dot pips ── */
.dot-pips {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.dot-pip {
width: 10px;
height: 10px;
border-radius: var(--radius-dot);
background: var(--forest);
transition: background var(--transition), transform 200ms ease;
}
.dot-pip.empty {
background: transparent;
border: 1.5px solid var(--bone);
/* slightly darker border for clarity */
border-color: rgba(44, 26, 14, 0.18);
}
.dot-pip.vanish {
transform: scale(0);
}
/* ── Countdown ── */
.countdown-row {
display: flex;
align-items: center;
gap: 6px;
padding: 14px 24px 0;
font-size: 0.8rem;
font-weight: 500;
color: var(--warm-gray);
}
.countdown-icon {
font-size: 0.9rem;
}
/* ── CTA button ── */
.cta-button {
display: block;
width: calc(100% - 48px);
margin: 20px 24px 0;
background: var(--forest);
color: var(--bone);
border: none;
border-radius: var(--radius-btn);
padding: 16px 24px;
font-family: 'Inter', sans-serif;
font-size: 1rem;
font-weight: 700;
letter-spacing: 0.02em;
cursor: pointer;
transition: background var(--transition), transform var(--transition), box-shadow var(--transition);
position: relative;
overflow: hidden;
}
.cta-button:hover:not(:disabled) {
background: var(--forest-d);
box-shadow: 0 4px 16px rgba(52, 95, 64, 0.35);
transform: translateY(-1px);
}
.cta-button:active:not(:disabled) {
transform: translateY(0);
box-shadow: none;
}
.cta-button:disabled {
background: var(--warm-gray);
cursor: not-allowed;
opacity: 0.7;
}
/* Ripple effect */
.cta-button .ripple {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.25);
transform: scale(0);
animation: ripple-anim 500ms linear;
pointer-events: none;
}
@keyframes ripple-anim {
to {
transform: scale(4);
opacity: 0;
}
}/* ── Chef's Daily Special Card — Interactive Logic ── */
(function () {
'use strict';
/* ── State ── */
const MAX_PORTIONS = 8;
let portions = MAX_PORTIONS;
/* ── DOM refs ── */
const ctaBtn = document.getElementById('cta-button');
const dotPipsEl = document.getElementById('dot-pips');
const availLabel = document.getElementById('availability-label');
const countdownText = document.getElementById('countdown-text');
const soldOutOverlay = document.getElementById('sold-out-overlay');
/* ────────────────────────────────────────────
Dot pips renderer
──────────────────────────────────────────── */
function renderDots() {
dotPipsEl.innerHTML = '';
for (let i = 0; i < MAX_PORTIONS; i++) {
const dot = document.createElement('span');
dot.className = 'dot-pip' + (i >= portions ? ' empty' : '');
dot.setAttribute('aria-hidden', 'true');
dotPipsEl.appendChild(dot);
}
}
/* ────────────────────────────────────────────
Availability label updater
──────────────────────────────────────────── */
function updateAvailabilityLabel() {
if (portions === 0) {
availLabel.textContent = 'No portions remaining';
availLabel.classList.add('low');
} else if (portions === 1) {
availLabel.textContent = '1 portion available — last one!';
availLabel.classList.add('low');
} else if (portions <= 3) {
availLabel.textContent = portions + ' portions available — almost gone!';
availLabel.classList.add('low');
} else {
availLabel.textContent = portions + ' portions available';
availLabel.classList.remove('low');
}
}
/* ────────────────────────────────────────────
Sold-out state
──────────────────────────────────────────── */
function triggerSoldOut() {
ctaBtn.disabled = true;
ctaBtn.textContent = 'Sold out tonight';
soldOutOverlay.setAttribute('aria-hidden', 'false');
soldOutOverlay.classList.add('visible');
}
/* ────────────────────────────────────────────
Ripple helper
──────────────────────────────────────────── */
function createRipple(event) {
const button = event.currentTarget;
const circle = document.createElement('span');
const diameter = Math.max(button.clientWidth, button.clientHeight);
const radius = diameter / 2;
const rect = button.getBoundingClientRect();
circle.style.width = circle.style.height = diameter + 'px';
circle.style.left = (event.clientX - rect.left - radius) + 'px';
circle.style.top = (event.clientY - rect.top - radius) + 'px';
circle.className = 'ripple';
const existingRipple = button.querySelector('.ripple');
if (existingRipple) existingRipple.remove();
button.appendChild(circle);
}
/* ────────────────────────────────────────────
CTA click handler
──────────────────────────────────────────── */
ctaBtn.addEventListener('click', function (e) {
if (portions <= 0) return;
createRipple(e);
/* Animate the last filled dot out */
const dots = dotPipsEl.querySelectorAll('.dot-pip:not(.empty)');
const lastFilled = dots[dots.length - 1];
if (lastFilled) {
lastFilled.classList.add('vanish');
setTimeout(function () {
portions -= 1;
renderDots();
updateAvailabilityLabel();
if (portions === 0) {
triggerSoldOut();
}
}, 180);
} else {
portions -= 1;
renderDots();
updateAvailabilityLabel();
if (portions === 0) {
triggerSoldOut();
}
}
});
/* ────────────────────────────────────────────
Countdown timer
Reference "now" = 20:46 (fixed, since Date is environment-dependent)
Service window: 19:00 – 23:00
Kitchen closes: 23:00
──────────────────────────────────────────── */
var NOW_HOURS = 20;
var NOW_MINUTES = 46;
var NOW_SECONDS = 0;
/* Total seconds since midnight for the fixed "now" */
var nowTotalSeconds = (NOW_HOURS * 3600) + (NOW_MINUTES * 60) + NOW_SECONDS;
/* Closing time: 23:00 */
var closingTotalSeconds = 23 * 3600;
/* Service opens: 19:00 */
var openingTotalSeconds = 19 * 3600;
/* Elapsed seconds since the page loaded — incremented by setInterval */
var elapsedSeconds = 0;
function getCountdownString() {
var currentSeconds = nowTotalSeconds + elapsedSeconds;
if (currentSeconds < openingTotalSeconds) {
return 'Service opens at 19:00';
}
if (currentSeconds >= closingTotalSeconds) {
return 'Kitchen is closed for tonight';
}
var remaining = closingTotalSeconds - currentSeconds;
var hours = Math.floor(remaining / 3600);
var minutes = Math.floor((remaining % 3600) / 60);
if (hours > 0) {
return 'Service closes in ' + hours + 'h ' + minutes + 'm';
}
return 'Service closes in ' + minutes + 'm';
}
function updateCountdown() {
countdownText.textContent = getCountdownString();
}
/* Initial render */
updateCountdown();
/* Tick every 60 seconds (1 minute) */
setInterval(function () {
elapsedSeconds += 60;
updateCountdown();
}, 60000);
/* ────────────────────────────────────────────
Initial render
──────────────────────────────────────────── */
renderDots();
updateAvailabilityLabel();
})();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Chef's Daily Special Card</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;800&family=Inter:wght@400;500;600;700&display=swap" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page-wrapper">
<div class="card" id="special-card">
<!-- Sold-out overlay -->
<div class="sold-out-overlay" id="sold-out-overlay" aria-hidden="true">
<div class="sold-out-stamp">SOLD OUT</div>
</div>
<!-- Eyebrow -->
<div class="eyebrow">Tonight Only</div>
<!-- Dish photo placeholder -->
<div class="photo-placeholder" aria-label="Dish photo">
<span class="photo-emoji">🍽</span>
</div>
<!-- Chef badge -->
<div class="chef-badge">
<div class="chef-avatar" aria-hidden="true">CM</div>
<span class="chef-label">Chef Marco</span>
</div>
<!-- Dish info -->
<div class="dish-info">
<h1 class="dish-title">Branzino al Cartoccio</h1>
<p class="dish-subtitle">Pan-seared Mediterranean sea bass · Summer harvest</p>
</div>
<!-- Tasting notes -->
<p class="tasting-notes">
Delicate sea bass slow-roasted in parchment with heirloom tomatoes, Taggiasca olives, capers, and a drizzle of lemon-herb oil. Finished with micro basil and a sprinkle of fleur de sel.
</p>
<!-- Allergen chips -->
<div class="allergen-row" aria-label="Allergens">
<span class="allergen-chip">🌾 gluten</span>
<span class="allergen-chip">🥛 dairy</span>
<span class="allergen-chip">🥜 nuts</span>
</div>
<!-- Divider -->
<div class="divider"></div>
<!-- Availability -->
<div class="availability-block">
<div class="availability-header">
<span class="availability-label" id="availability-label">8 portions available</span>
<span class="price">$38</span>
</div>
<div class="dot-pips" id="dot-pips" aria-label="Portions remaining" role="img"></div>
</div>
<!-- Countdown -->
<div class="countdown-row">
<span class="countdown-icon" aria-hidden="true">⏱</span>
<span class="countdown-text" id="countdown-text">Loading…</span>
</div>
<!-- CTA -->
<button class="cta-button" id="cta-button" type="button">
Add to order
</button>
</div>
</div>
<script src="script.js"></script>
</body>
</html>Chef’s Daily Special Card
Displays tonight’s chef special with a live availability counter (e.g. “6 portions left”) that decrements on CTA click, a countdown timer showing time until the kitchen closes (23:00), allergen chips, and a tasting-note block. Sold-out state animates in when portions reach zero.