Skip to content
Motion
FreeIn reviewv1.0.0

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-teaser

Controls

Variant
<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

  1. 1Add the VibeBox registry to components.json:
    {
      "registries": {
        "@vibebox": "https://motion.vibeboxph.com/r/{name}.json"
      }
    }
  2. 2Add the block with the shadcn CLI:
    npx shadcn@latest add @vibebox/menu-teaser
  3. 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 files
components/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.

SlotLabelTypeLimitFact
eyebrowKickertext32 chars—
headingHeadingtext60 chars—
leadIntrotext160 chars—
itemsItemslistup to 12—
items[].nameNametext48 chars—
items[].descriptionDescriptiontext120 chars—
items[].pricePriceprice32 charsYes, a fill-in until given
items[].tagTagtext16 chars—
items[].mediaImageimage——
actionButtonaction24 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").
  • Menu cards with photos, one turned over to show its description

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

    Menus block · CSS

  • A tall horizon scene beside the story of a family dental clinic and three details

    The business in its own words: a tall photo with a caption beside the story and a few honest details.

    About block · Server

  • A headline about moving better beside a mosaic of a tile scene, a bloom scene and a class list

    The promise beside a three-tile mosaic: a tall photo, a second image and a card of what the business offers.

    Heroes block · Server

↑ ↓ to move↵ to openesc to close