Menu Teaser
Dishes, products or packages with a line each and prices the business sets, as a menu list or cards.
npx shadcn@latest add @vibebox/menu-teaserControls
<MenuTeaser />About
A new section type for food, hospitality and retail sites. It teases the menu; a full catalogue with checkout is a Phase 7 module.
Use it when
- The business sells dishes, products or packages with prices.
Skip it when
- Visitors need to buy online: the catalogue module replaces it (Phase 7).
Install
- 1Add the VibeBox registry to
components.json:{ "registries": { "@vibebox": "https://motion.vibeboxph.com/r/{name}.json" } } - 2Add the block with the shadcn CLI:
npx shadcn@latest add @vibebox/menu-teaser - 3Use it:
import { MenuTeaser } from "@/components/vibebox/menu-teaser"; import { home } from "@/content/pages/home"; <MenuTeaser content={home.menu} variant="list" id="menu" />
Code
2 filescomponents/vibebox/menu-teaser/index.tsx
/**
* VibeBox Motion · Menu Teaser
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
import { Container, headingId, Media, REVEAL, Section, SectionHead, SlotText, TextLink, type BlockProps } from "@/lib/vibebox/block-kit";
import type { Image } from "@/lib/vibebox/content";
import { cn } from "@/lib/utils";
import type { Content, Variant } from "./schema";
/*
* A taste of the menu or catalogue: names with a line each and prices the business sets, as a
* two-column menu with dotted leaders or as cards with images.
*/
const FALLBACK_SCENES = ["vase", "archway", "hills", "tiles"] as const;
export type MenuTeaserProps = BlockProps<Content, Variant>;
export function MenuTeaser({ content, variant = "list", id, tone, className }: MenuTeaserProps) {
const cards = variant === "cards";
return (
<Section slug="menu-teaser" variant={variant} id={id} tone={tone} labelledBy={headingId(id)} className={className}>
<Container className="grid gap-[clamp(32px,4vw,56px)]">
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
<SectionHead eyebrow={content.eyebrow} heading={content.heading} lead={content.lead} id={id} />
{content.action && <TextLink link={content.action} path="action" className="justify-self-start lg:justify-self-end" />}
</div>
{cards ? (
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{content.items.map((item, index) => {
const fallback: Image = { src: `scene:${FALLBACK_SCENES[index % FALLBACK_SCENES.length]}:${index % 10}`, alt: "", width: 1200, height: 900 };
return (
<li key={index} className={cn("grid content-start overflow-hidden rounded-xl bg-card text-card-foreground ring-1 ring-border", REVEAL)}>
<div className="aspect-[4/3]">
<Media image={item.media ?? fallback} sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw" />
</div>
<div className="grid gap-2 p-5">
<div className="flex items-baseline justify-between gap-4">
<SlotText as="h3" value={item.name} path={`items.${index}.name`} className="min-w-0 font-semibold tracking-tight" />
<SlotText value={item.price} path={`items.${index}.price`} className="shrink-0 font-medium tabular-nums" />
</div>
<SlotText as="p" value={item.description} path={`items.${index}.description`} className="text-small text-pretty text-muted-foreground" />
{item.tag && (
<SlotText value={item.tag} path={`items.${index}.tag`} className="justify-self-start rounded-full bg-muted px-2.5 py-0.5 text-caption font-medium" />
)}
</div>
</li>
);
})}
</ul>
) : (
<ul className="grid gap-x-14 gap-y-7 md:grid-cols-2">
{content.items.map((item, index) => (
<li key={index} className={cn("grid gap-1.5", REVEAL)}>
<div className="flex items-baseline gap-3">
<SlotText as="h3" value={item.name} path={`items.${index}.name`} className="min-w-0 text-[1.1rem] font-semibold tracking-tight" />
{item.tag && <SlotText value={item.tag} path={`items.${index}.tag`} className="shrink-0 rounded-full bg-muted px-2 py-px text-caption font-medium" />}
<span aria-hidden="true" className="min-w-6 flex-1 translate-y-[-4px] border-b border-dotted border-border-strong" />
<SlotText value={item.price} path={`items.${index}.price`} className="shrink-0 font-medium tabular-nums" />
</div>
<SlotText as="p" value={item.description} path={`items.${index}.description`} className="text-small text-pretty text-muted-foreground" />
</li>
))}
</ul>
)}
</Container>
</Section>
);
}components/vibebox/menu-teaser/schema.ts
/**
* VibeBox Motion · Menu Teaser
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
import { sections, type z } from "@/lib/vibebox/content";
export const variants = ["list", "cards"] as const;
export type Variant = (typeof variants)[number];
export const content = sections.menu;
export type Content = z.infer<typeof content>;Content
Typed slots from schema.ts. Facts (hours, prices, quotes, names) come only from the business; until then they render as fill-ins.
| Slot | Label | Type | Limit | Fact |
|---|---|---|---|---|
| eyebrow | Kicker | text | 32 chars | — |
| heading | Heading | text | 60 chars | — |
| lead | Intro | text | 160 chars | — |
| items | Items | list | up to 12 | — |
| items[].name | Name | text | 48 chars | — |
| items[].description | Description | text | 120 chars | — |
| items[].price | Price | price | 32 chars | Yes, a fill-in until given |
| items[].tag | Tag | text | 16 chars | — |
| items[].media | Image | image | — | — |
| action | Button | action | 24 chars | — |
Variants
Menu listdefault
list
Two columns of names, dotted leaders and prices.
Cards
cards
Image cards, three across on desktop.
Accessibility and motion
- Keyboard
- The optional link to the full menu.
- Screen readers
- A heading and a list of items, each a heading with its price, tag and description; leaders are hidden.
- Touch
- Nothing to touch.
- Reduced motion
- Items appear without their reveal.
Edge cases and limits
- Long names wrap before the leader shrinks below 24 px.
- Prices are free text ("₱480 per plate").
Related

Menu cards that turn over: photo, name and price in front; the description and tag behind.
Menus block · CSS

About Story
FreeThe business in its own words: a tall photo with a caption beside the story and a few honest details.
About block · Server

Bento Hero
FreeThe promise beside a three-tile mosaic: a tall photo, a second image and a card of what the business offers.
Heroes block · Server