Menu Pricing
Pricing section styled as a printed menu for a coffee subscription: dotted leaders, size columns and a pen-circled pick.
- Pricing
- Food & beverage
- React only · 0 deps
- 1 file
Browser support · All evergreen browsers. color-mix() for leaders and focus rings (Chrome 111+, Safari 16.2+, Firefox 113+).
Install. One command, or copy the file.
$
npx shadcn@latest add https://freedesignmd.com/r/menu-pricing.json- Byte-identical to the preview
- No Tailwind or config needed
- SSR-safe · keyboard accessible
- Respects reduced motion & transparency
components/pricing/menu-pricing.tsx
1/**2 * MenuPricing, a coffee subscription priced like a printed restaurant menu:3 * roman-numeral sections, small-caps names, italic tasting notes, dotted4 * leaders running to three size columns. Pick a price and it gets circled in5 * pen; choose how often and how it's ground (a coarseness strip of particles6 * resizes to match) and the order slip on the right totals it.7 *8 * Props: sections ({ numeral, title, items[] }), sizes (column labels),9 * frequencies ({ id, label, note, discount, perMonth }), grinds ({ id, label,10 * level 0..6, whole? }), currencyLabel, house (name / place line).11 * Item: { id, name, origin, notes, prices[] } with one price per size.12 * CSS vars: --fd-mp-paper, --fd-mp-ink, --fd-mp-accent.13 *14 * Browser support: all evergreen browsers.15 * Accessibility: each price is a <button aria-pressed> labelled with item,16 * size and price; frequency and grind are native radio groups; the order slip17 * is a polite live region.18 * Reduced motion: the pen circle appears without drawing, particles snap.19 */20import { useId, useState } from "react";21 22export interface MenuItem {23 id: string;24 name: string;25 origin: string;26 notes: string;27 prices: number[];28}29export interface MenuSection {30 numeral: string;31 title: string;32 items: MenuItem[];33}34export interface Frequency {35 id: string;36 label: string;37 note: string;38 discount: number;39 perMonth: number;40}41export interface Grind {42 id: string;43 label: string;44 level: number;45 whole?: boolean;46}47 48export interface MenuPricingProps {49 sections?: MenuSection[];50 sizes?: string[];51 frequencies?: Frequency[];52 grinds?: Grind[];53 currencyLabel?: string;54 house?: { name: string; line: string; season: string };55 className?: string;56}57 58const SECTIONS: MenuSection[] = [59 {60 numeral: "I",61 title: "Filter, single origin",62 items: [63 { id: "huila", name: "Huila, La Esperanza", origin: "Colombia · washed · 1,850 m", notes: "Bergamot, black tea, raw honey", prices: [115, 210, 390] },64 { id: "guji", name: "Guji Hambela", origin: "Ethiopia · natural · 2,100 m", notes: "Blueberry, cacao nib, jasmine", prices: [125, 230, 430] },65 { id: "kirinyaga", name: "Kirinyaga AB", origin: "Kenya · washed · 1,700 m", notes: "Blackcurrant, grapefruit, tomato leaf", prices: [135, 250, 470] },66 ],67 },68 {69 numeral: "II",70 title: "Espresso",71 items: [72 { id: "house", name: "Nordhavn House", origin: "Brazil & Guatemala blend", notes: "Hazelnut, milk chocolate, demerara", prices: [99, 185, 340] },73 { id: "huye", name: "Huye Mountain", origin: "Rwanda · washed · seasonal", notes: "Red apple, caramel, a little cola", prices: [120, 220, 410] },74 ],75 },76 {77 numeral: "III",78 title: "Decaf, done properly",79 items: [{ id: "decaf", name: "Sugarcane Decaf", origin: "Colombia · EA process", notes: "Toffee, orange peel, very soft", prices: [110, 200, 370] }],80 },81];82 83const SIZES = ["250 g", "500 g", "1 kg"];84 85const FREQS: Frequency[] = [86 { id: "week", label: "Every week", note: "10% off", discount: 0.1, perMonth: 52 / 12 },87 { id: "fortnight", label: "Every two weeks", note: "5% off", discount: 0.05, perMonth: 26 / 12 },88 { id: "month", label: "Every month", note: "menu price", discount: 0, perMonth: 1 },89];90 91const GRINDS: Grind[] = [92 { id: "whole", label: "Whole bean", level: 6, whole: true },93 { id: "espresso", label: "Espresso", level: 0 },94 { id: "moka", label: "Moka pot", level: 1 },95 { id: "aeropress", label: "AeroPress", level: 2 },96 { id: "v60", label: "V60, pour-over", level: 3 },97 { id: "chemex", label: "Chemex", level: 4 },98 { id: "press", label: "French press", level: 5 },99];100 101function Particles({ grind }: { grind: Grind }) {102 const n = 14;103 return (104 <span className="fd-mp-parts" aria-hidden="true" data-whole={grind.whole ? "" : undefined}>105 {Array.from({ length: n }, (_, i) => {106 const jitter = ((i * 37) % 7) / 7;107 const size = grind.whole ? 12 : 2 + grind.level * 1.3 + jitter * (1 + grind.level * 0.5);108 return <i key={i} style={{ width: size, height: grind.whole ? size * 0.72 : size, transitionDelay: `${i * 14}ms` }} />;109 })}110 </span>111 );112}113 114export function MenuPricing({115 sections = SECTIONS,116 sizes = SIZES,117 frequencies = FREQS,118 grinds = GRINDS,119 currencyLabel = "Danish kroner, delivery included across Denmark",120 house = { name: "Kaffe Halden", line: "Roasted Tuesdays in Nordhavn, posted Wednesday", season: "Subscription menu · Autumn 2026" },121 className,122}: MenuPricingProps) {123 const uid = useId().replace(/[^a-zA-Z0-9]/g, "");124 const [pick, setPick] = useState<{ item: string; size: number }>({ item: "huila", size: 1 });125 const [freq, setFreq] = useState(frequencies[1]?.id ?? frequencies[0].id);126 const [grind, setGrind] = useState(grinds[4]?.id ?? grinds[0].id);127 const items = sections.flatMap((s) => s.items);128 const item = items.find((i) => i.id === pick.item) ?? items[0];129 const f = frequencies.find((x) => x.id === freq) ?? frequencies[0];130 const g = grinds.find((x) => x.id === grind) ?? grinds[0];131 const base = item.prices[pick.size];132 const per = Math.round(base * (1 - f.discount));133 const month = Math.round(per * f.perMonth);134 135 return (136 <section className={`fd-mp${className ? " " + className : ""}`} aria-label={`${house.name} subscription menu`}>137 <style>{css}</style>138 <div className="fd-mp-paper">139 <header className="fd-mp-head">140 <p className="fd-mp-season">{house.season}</p>141 <h2 className="fd-mp-name">{house.name}</h2>142 <svg className="fd-mp-orn" width="120" height="14" viewBox="0 0 120 14" aria-hidden="true">143 <path d="M0 7h48M72 7h48" stroke="currentColor" strokeWidth=".8" />144 <path d="M60 1.5c3 3 3 8 0 11-3-3-3-8 0-11zM54 7c2-1.6 4-1.6 6 0-2 1.6-4 1.6-6 0zM66 7c-2-1.6-4-1.6-6 0 2 1.6 4 1.6 6 0z" fill="currentColor" />145 </svg>146 <p className="fd-mp-line">{house.line}</p>147 </header>148 149 <div className="fd-mp-body">150 <div className="fd-mp-menu">151 <div className="fd-mp-sizes" aria-hidden="true">152 {sizes.map((s) => (153 <span key={s}>{s}</span>154 ))}155 </div>156 {sections.map((sec) => (157 <div key={sec.numeral} className="fd-mp-sec">158 <h3 className="fd-mp-sec-t">159 <span>{sec.numeral}.</span> {sec.title}160 </h3>161 <ul>162 {sec.items.map((it) => (163 <li key={it.id} className="fd-mp-item" data-on={it.id === item.id ? "" : undefined}>164 <div className="fd-mp-row">165 <span className="fd-mp-iname">{it.name}</span>166 <span className="fd-mp-dots" aria-hidden="true" />167 <span className="fd-mp-prices">168 {it.prices.map((p, si) => {169 const on = it.id === item.id && si === pick.size;170 return (171 <button172 key={si}173 type="button"174 className="fd-mp-p"175 aria-pressed={on}176 aria-label={`${it.name}, ${sizes[si]}, ${p} kroner`}177 onClick={() => setPick({ item: it.id, size: si })}178 >179 {p}180 {on && (181 <svg className="fd-mp-circle" viewBox="0 0 60 34" preserveAspectRatio="none" aria-hidden="true">182 <path pathLength={1} d="M41 4C30 1 9 3 4 13c-4 9 10 18 28 17s26-8 24-17C54 5 40 2 26 4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />183 </svg>184 )}185 </button>186 );187 })}188 </span>189 </div>190 <p className="fd-mp-notes">191 <em>{it.notes}.</em> <span>{it.origin}</span>192 </p>193 </li>194 ))}195 </ul>196 </div>197 ))}198 <p className="fd-mp-cur">Prices per bag in {currencyLabel}.</p>199 </div>200 201 <aside className="fd-mp-side" aria-label="Your subscription">202 <fieldset className="fd-mp-fs">203 <legend>How often</legend>204 {frequencies.map((x) => (205 <label key={x.id} className="fd-mp-opt">206 <input type="radio" name={`${uid}-f`} value={x.id} checked={freq === x.id} onChange={() => setFreq(x.id)} />207 <span className="fd-mp-radio" aria-hidden="true" />208 <span className="fd-mp-ol">{x.label}</span>209 <span className="fd-mp-on">{x.note}</span>210 </label>211 ))}212 </fieldset>213 214 <fieldset className="fd-mp-fs">215 <legend>Ground for</legend>216 <Particles grind={g} />217 <div className="fd-mp-grinds">218 {grinds.map((x) => (219 <label key={x.id} className="fd-mp-chip">220 <input type="radio" name={`${uid}-g`} value={x.id} checked={grind === x.id} onChange={() => setGrind(x.id)} />221 <span>{x.label}</span>222 </label>223 ))}224 </div>225 <p className="fd-mp-scale" aria-hidden="true">226 <span>fine</span>227 <span>coarse</span>228 </p>229 </fieldset>230 231 <div className="fd-mp-slip" aria-live="polite">232 <p className="fd-mp-slip-h">Your order</p>233 <p className="fd-mp-slip-item">234 {item.name}, {sizes[pick.size]}235 </p>236 <p className="fd-mp-slip-meta">237 {f.label.toLowerCase()}, {g.whole ? "whole bean" : `ground for ${g.label.toLowerCase()}`}238 </p>239 <div className="fd-mp-slip-row">240 <span>Per delivery</span>241 <span className="fd-mp-dots" aria-hidden="true" />242 <b>243 {f.discount > 0 && <s>{base}</s>} kr {per}244 </b>245 </div>246 <div className="fd-mp-slip-row fd-mp-slip-sub">247 <span>About per month</span>248 <span className="fd-mp-dots" aria-hidden="true" />249 <span>kr {month}</span>250 </div>251 <a className="fd-mp-cta" href="#subscribe">252 Subscribe, pause any time253 </a>254 <p className="fd-mp-fine">Skip or swap a coffee until Sunday 23:59 before roast day.</p>255 </div>256 </aside>257 </div>258 </div>259 </section>260 );261}262 263const css = `264:where(.fd-mp,.fd-mp *){box-sizing:border-box;margin:0;padding:0;border:0;background:none;font:inherit;letter-spacing:inherit;color:inherit;text-decoration:none;list-style:none;min-width:0}265:where(.fd-mp,.fd-mp *)::before,:where(.fd-mp,.fd-mp *)::after{box-sizing:border-box}266:where(.fd-mp svg){display:block;flex-shrink:0}267:where(.fd-mp button){appearance:none;-webkit-appearance:none;cursor:pointer;border-radius:0}268.fd-mp{269 --fd-ease-out:cubic-bezier(.22,1,.36,1);--fd-ease-in-out:cubic-bezier(.65,0,.35,1);--fd-spring:cubic-bezier(.34,1.56,.64,1);270 --fd-dur-1:120ms;--fd-dur-2:240ms;--fd-dur-3:420ms;--fd-dur-4:700ms;271 --fd-mp-paper:#F5EEDF;--fd-mp-ink:#1F1B15;--fd-mp-accent:#2F5240;272 --mut:#6E6556;--line:rgba(31,27,21,.22);273 --serif:"Iowan Old Style","Palatino Linotype",Palatino,"Book Antiqua",Georgia,serif;274 display:block;width:100%;background:#E9E0CC;color:var(--fd-mp-ink);font:400 16px/24px var(--serif);-webkit-font-smoothing:antialiased;275 padding:clamp(24px,5vw,72px) clamp(12px,4vw,56px)}276.fd-mp-paper{max-width:1120px;margin:0 auto;background:var(--fd-mp-paper);padding:clamp(28px,5vw,64px) clamp(20px,5vw,72px);box-shadow:0 1px 2px rgba(60,45,20,.12),0 24px 48px -28px rgba(60,45,20,.35);outline:1px solid var(--line);outline-offset:-10px;277 background-image:radial-gradient(rgba(31,27,21,.035) 1px,transparent 1px);background-size:5px 5px}278.fd-mp-head{text-align:center}279.fd-mp-season{font:400 12px/16px var(--serif);letter-spacing:.24em;text-transform:uppercase;color:var(--mut)}280.fd-mp-name{margin-top:14px;font:400 clamp(2.5rem,6vw,4.5rem)/1 var(--serif);letter-spacing:.02em;font-variant:small-caps}281.fd-mp-orn{margin:18px auto 0;color:var(--fd-mp-ink)}282.fd-mp-line{margin-top:12px;font:italic 400 15px/22px var(--serif);color:var(--mut)}283.fd-mp-body{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(280px,1fr);gap:clamp(32px,5vw,72px);margin-top:clamp(36px,5vw,56px)}284.fd-mp-sizes{display:flex;justify-content:flex-end;gap:0;padding-bottom:6px;border-bottom:1px solid var(--line)}285.fd-mp-sizes span{width:58px;text-align:center;font:400 11px/16px var(--serif);letter-spacing:.14em;text-transform:uppercase;color:var(--mut)}286.fd-mp-sec{margin-top:28px}287.fd-mp-sec-t{font:400 13px/18px var(--serif);letter-spacing:.22em;text-transform:uppercase;color:var(--fd-mp-accent)}288.fd-mp-sec-t span{font-style:italic;letter-spacing:.04em;margin-right:4px}289.fd-mp-item{padding:14px 0 4px;border-bottom:1px dotted var(--line)}290.fd-mp-row{display:flex;align-items:baseline;gap:8px}291.fd-mp-iname{font:400 1.25rem/28px var(--serif);font-variant:small-caps;letter-spacing:.04em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:0 1 auto}292.fd-mp-dots{flex:1 1 24px;min-width:16px;height:0;border-bottom:2px dotted color-mix(in srgb,var(--fd-mp-ink) 45%,transparent);transform:translateY(-5px)}293.fd-mp-prices{display:flex;flex:none}294.fd-mp-p{position:relative;width:58px;height:34px;font:400 1.125rem/34px var(--serif);font-variant-numeric:oldstyle-nums proportional-nums;color:var(--fd-mp-ink);text-align:center;border-radius:9999px;transition:color var(--fd-dur-1),background-color var(--fd-dur-1)}295.fd-mp-p:hover{background:rgba(31,27,21,.06)}296.fd-mp-p[aria-pressed="true"]{color:var(--fd-mp-accent);font-weight:600}297.fd-mp-p:focus-visible{outline:2px solid color-mix(in srgb,var(--fd-mp-accent) 70%,transparent);outline-offset:0}298.fd-mp-circle{position:absolute;inset:-1px -2px;width:calc(100% + 4px);height:calc(100% + 2px);color:var(--fd-mp-accent);pointer-events:none}299.fd-mp-circle path{stroke-dasharray:1.2;stroke-dashoffset:1.2;animation:fd-mp-draw var(--fd-dur-4) var(--fd-ease-out) forwards}300@keyframes fd-mp-draw{to{stroke-dashoffset:0}}301.fd-mp-notes{padding-bottom:10px;font:400 14px/20px var(--serif);color:var(--mut)}302.fd-mp-notes em{font-style:italic;color:var(--fd-mp-ink)}303.fd-mp-notes span{white-space:nowrap}304.fd-mp-cur{margin-top:20px;font:italic 400 13px/18px var(--serif);color:var(--mut)}305 306.fd-mp-side{display:flex;flex-direction:column;gap:28px;padding-left:clamp(0px,3vw,40px);border-left:1px solid var(--line)}307.fd-mp-fs{min-width:0}308.fd-mp-fs legend{padding:0 0 10px;font:400 12px/16px var(--serif);letter-spacing:.22em;text-transform:uppercase;color:var(--fd-mp-accent)}309.fd-mp-opt{position:relative;display:flex;align-items:baseline;gap:10px;padding:8px 0;cursor:pointer;border-bottom:1px dotted var(--line)}310.fd-mp-opt input,.fd-mp-chip input{position:absolute;opacity:0;width:1px;height:1px;margin:0}311.fd-mp-radio{position:relative;top:2px;width:14px;height:14px;flex:none;border-radius:50%;box-shadow:inset 0 0 0 1px var(--fd-mp-ink)}312.fd-mp-radio::after{content:"";position:absolute;inset:3px;border-radius:50%;background:var(--fd-mp-ink);transform:scale(0);transition:transform var(--fd-dur-2) var(--fd-spring)}313.fd-mp-opt input:checked+.fd-mp-radio::after{transform:scale(1)}314.fd-mp-opt input:focus-visible+.fd-mp-radio{outline:2px solid color-mix(in srgb,var(--fd-mp-accent) 70%,transparent);outline-offset:2px}315.fd-mp-ol{flex:1;font:400 17px/24px var(--serif)}316.fd-mp-on{font:italic 400 14px/20px var(--serif);color:var(--mut)}317.fd-mp-parts{display:flex;align-items:center;justify-content:space-between;height:28px;padding:0 2px;margin-bottom:12px}318.fd-mp-parts i{display:block;border-radius:40%;background:#5B3A22;transition:width var(--fd-dur-3) var(--fd-spring),height var(--fd-dur-3) var(--fd-spring),border-radius var(--fd-dur-3)}319.fd-mp-parts[data-whole] i{border-radius:50%;background:linear-gradient(90deg,#6B4429 44%,#1A0F07 47% 53%,#6B4429 56%)}320.fd-mp-grinds{display:flex;flex-wrap:wrap;gap:6px}321.fd-mp-chip{position:relative;cursor:pointer}322.fd-mp-chip span{display:block;padding:5px 11px;border-radius:9999px;font:400 14px/20px var(--serif);box-shadow:inset 0 0 0 1px var(--line);transition:background-color var(--fd-dur-1),color var(--fd-dur-1),box-shadow var(--fd-dur-1)}323.fd-mp-chip:hover span{box-shadow:inset 0 0 0 1px var(--fd-mp-ink)}324.fd-mp-chip input:checked+span{background:var(--fd-mp-ink);color:var(--fd-mp-paper);box-shadow:none}325.fd-mp-chip input:focus-visible+span{outline:2px solid color-mix(in srgb,var(--fd-mp-accent) 70%,transparent);outline-offset:2px}326.fd-mp-scale{display:flex;justify-content:space-between;margin-top:10px;font:italic 400 12px/16px var(--serif);color:var(--mut)}327.fd-mp-slip{padding:20px;background:#FBF7EE;box-shadow:inset 0 0 0 1px var(--line)}328.fd-mp-slip-h{font:400 11px/16px var(--serif);letter-spacing:.24em;text-transform:uppercase;color:var(--mut)}329.fd-mp-slip-item{margin-top:8px;font:400 1.375rem/28px var(--serif);font-variant:small-caps;letter-spacing:.03em}330.fd-mp-slip-meta{font:italic 400 15px/22px var(--serif);color:var(--mut)}331.fd-mp-slip-row{display:flex;align-items:baseline;gap:8px;margin-top:16px;font:400 16px/24px var(--serif)}332.fd-mp-slip-row b{font-weight:600;font-size:1.25rem;font-variant-numeric:lining-nums}333.fd-mp-slip-row s{text-decoration:line-through;margin-right:6px;font-weight:400;font-size:.875rem;color:var(--mut)}334.fd-mp-slip-sub{margin-top:4px;color:var(--mut);font-size:14px}335.fd-mp-cta{display:flex;align-items:center;justify-content:center;margin-top:20px;height:48px;background:var(--fd-mp-accent);color:#F5EEDF;font:400 14px/48px var(--serif);letter-spacing:.14em;text-transform:uppercase;transition:background-color var(--fd-dur-1),transform var(--fd-dur-2) var(--fd-spring)}336.fd-mp-cta:hover{background:var(--fd-mp-ink)}337.fd-mp-cta:active{transform:scale(.96);transition-duration:var(--fd-dur-1)}338.fd-mp-cta:focus-visible{outline:2px solid color-mix(in srgb,var(--fd-mp-accent) 70%,transparent);outline-offset:2px}339.fd-mp-fine{margin-top:12px;font:italic 400 13px/18px var(--serif);color:var(--mut)}340@media (max-width:880px){341 .fd-mp-body{grid-template-columns:1fr}342 .fd-mp-side{padding-left:0;border-left:0;padding-top:28px;border-top:1px solid var(--line)}343}344@media (max-width:480px){345 .fd-mp-sizes span,.fd-mp-p{width:44px}346 .fd-mp-p{font-size:1rem}347 .fd-mp-iname{font-size:1.0625rem;white-space:normal}348 .fd-mp-dots{display:none}349 .fd-mp-row{justify-content:space-between}350 .fd-mp-slip-row .fd-mp-dots{display:block}351 .fd-mp-notes span{white-space:normal}352}353@media (prefers-reduced-motion:reduce){.fd-mp *{transition-duration:1ms!important;transition-delay:0ms!important}.fd-mp-circle path{animation:none;stroke-dashoffset:0}}354@media (prefers-contrast:more){.fd-mp{--line:var(--fd-mp-ink)}}355`;356 357export function Demo() {358 return <MenuPricing />;359}More Pricing. Same file contract.
Questions about Menu Pricing
Run npx shadcn@latest add https://freedesignmd.com/r/menu-pricing.json, or open the page and copy the file into your project. It has zero dependencies besides React. What you copy is the exact file that renders the live demo.
Pricing section styled as a printed menu for a coffee subscription: dotted leaders, size columns and a pen-circled pick. It is a free, MIT-licensed React pricing component from the freedesignmd library.
Yes. It is free and MIT-licensed, use, modify and ship it in personal and commercial projects without attribution.
All evergreen browsers. color-mix() for leaders and focus rings (Chrome 111+, Safari 16.2+, Firefox 113+).
Yes. It is a plain React component with its own styles, so it runs in Next.js, Vite, Remix and Lovable projects, and it needs no Tailwind setup. It has zero dependencies besides React.


