Motiva
Мои коллекции
← каталог

Menu List

Меню с ценами — третий вариант вида pricing для локального бизнеса: группы позиций с точечной отбивкой до цены, как в бумажном меню; данные плоские (позиция несёт свою группу), появление групп по прокрутке на view(), 0 КБ JS.

cssrevealbasic~3 КБreduced-motion ✓
↗
↕ демо длиннее кадра — прокрутите его

Редактируемые поля

Секция «pricing». Весь контент приходит пропсами — ниже то, что конструктор даст поправить, не трогая код.

ПропЧто этоТип
eyebrowНадпись сверхуtext
titleЗаголовокtext
subПодзаголовокtext
itemsПозицииlist · до 18
noteСноскаtext

Все пропсы

Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.

ПропТипПо умолчаниюЧто это
eyebrow?string"меню"
title?string"Печём утром — к вечеру полки пустые"
sub?string"Всё из своей печи. Что закончилось — за…
items?MenuItem[]ITEMS
note?string"Цены действуют на месте и с собой. Про …Сноска под меню: аллергены, часы кухни.
background?ReactNode—Сокет фона: любой эффект с `mount.role: "background"`, например `<AuroraField />`. Занят — секция снимает собственную заливку, чтобы не спорить за слой.
MenuItem — что внутри
  • group: string— Группа меню (формат «Выпечка») — позиции с одинаковой группой собираются вместе.
  • name: string
  • price: string— Цена строкой вместе с валютой (формат «240 ₽»).
  • note?: string— Строка под названием: состав, объём.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/menu-list.json
Что нужно в проекте до установки
  1. Файл components.json в корне

    Из него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:

    "aliases": { "components": "@/components", "lib": "@/lib" }

    Нет файла — установка не начнётся вовсе.

  2. Алиас @ объявлен дважды

    В корневом tsconfig.json и в конфиге сборщика. В Vite корневой tsconfig часто содержит только references — этого мало.

    "paths": { "@/*": ["./src/*"] }        // tsconfig.json
    resolve.alias = { "@": "/src" }        // vite.config.ts

    Без paths CLI молча создаёт папку с именем @.

  3. Токены подключены один раз на приложение

    Они приезжают вместе с записью — достаточно импортировать их в точке входа:

    import "@/styles/motiva-tokens.css";

    Без них блок потеряет цвета, ритм и тему.

  4. Своя типографика не спорит с блоком

    Наследуемые свойства протекают внутрь: text-align: center на обёртке отцентрует содержимое секции. То же касается font-familyи line-height.

  5. Проект без TypeScript? всё равно нужен tsconfig

    CLI грузит tsconfig.json безусловно, а при "tsx": false идёт за jsconfig.json и падает так же. Минимум — файл с pathsи "tsx": true в components.json.

    Приехавший рядом .tsx можно не трогать — вам нужен .vanilla.js.

Экспорт

↓ Скачать .zip

Код

import type { ReactNode } from "react";
const css = `.mnl { position: relative; isolation: isolate; width: 100%;
  padding-block: var(--ds-section-y, var(--ds-8, 80px));
  background: var(--ds-bg, oklch(0.14 0.02 275));
  color: var(--ds-fg, oklch(0.95 0.01 265));
  font-family: var(--ds-font, system-ui, sans-serif); }
.mnl-in { max-width: var(--ds-container, 1120px); margin-inline: auto;
  padding-inline: var(--ds-gutter, clamp(18px, 5vw, 54px)); }
.mnl-eyebrow { margin: 0 0 var(--ds-2, 8px); text-align: center;
  font-family: var(--ds-font-mono, ui-monospace, monospace);
  font-size: var(--ds-text-xs, 0.75rem); letter-spacing: 0.16em; text-transform: uppercase;
  color: var(--ds-accent, oklch(0.72 0.16 300)); }
.mnl-h { margin: 0; text-align: center;
  font-size: var(--ds-text-xl, 2rem); font-weight: 750;
  letter-spacing: var(--ds-tracking-tight, -0.03em); }
.mnl-sub { margin: var(--ds-2, 10px) auto 0; max-width: 56ch; text-align: center;
  font-size: var(--ds-text-sm, 0.92rem); line-height: var(--ds-leading-body, 1.55);
  color: var(--ds-muted, oklch(0.72 0.02 265)); }

/* группы текут колонками; группа не рвётся на переносе */
.mnl-groups { margin: var(--ds-7, 54px) 0 0;
  display: grid; gap: var(--ds-6, 36px) clamp(28px, 5vw, 64px);
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
.mnl-group { min-width: 0; }
.mnl-gname { margin: 0 0 var(--ds-4, 16px);
  font-size: var(--ds-text-md, 1.05rem); font-weight: 750;
  letter-spacing: 0.02em; text-transform: uppercase;
  color: var(--ds-accent, oklch(0.72 0.16 300)); }

.mnl-items { margin: 0; padding: 0; list-style: none;
  display: grid; gap: var(--ds-4, 14px); }
.mnl-row { display: flex; align-items: baseline; gap: 10px; }
.mnl-name { font-size: var(--ds-text-sm, 0.95rem); font-weight: 600; }
/* точечная отбивка — классика бумажного меню: пунктир доводит взгляд до цены.
   Рисуется бордером у гибкого филлера, а не псевдоэлементом с шириной: филлер
   сам занимает ровно столько, сколько осталось между названием и ценой */
.mnl-dots { flex: 1; min-width: 24px;
  border-bottom: 2px dotted var(--ds-border, oklch(0.35 0.02 275));
  transform: translateY(-4px); }
.mnl-price { font-variant-numeric: tabular-nums; white-space: nowrap;
  font-size: var(--ds-text-sm, 0.95rem); font-weight: 700; }
.mnl-note { margin: 3px 0 0; flex-basis: 100%;
  font-size: var(--ds-text-xs, 0.78rem); line-height: 1.45;
  color: var(--ds-muted, oklch(0.72 0.02 265)); }
.mnl-item { display: flex; flex-wrap: wrap; align-items: baseline; }
.mnl-item .mnl-row { flex: 1 1 100%; }

.mnl-foot { margin: var(--ds-6, 36px) auto 0; max-width: 60ch; text-align: center;
  font-family: var(--ds-font-mono, ui-monospace, monospace);
  font-size: var(--ds-text-xs, 0.75rem);
  color: var(--ds-muted, oklch(0.72 0.02 265)); }

/* появление групп по прокрутке: 0 JS; не поддержано/reduce — меню просто стоит */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .mnl-group { animation: mnl-in 1s var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)) both;
      animation-timeline: view();
      animation-range: entry 0% entry 55%; }
  }
}
@keyframes mnl-in { from { opacity: 0; transform: translateY(16px); } }

/* СОКЕТ ФОНА — контракт как у остальных секций */
.mnl-bg { position: absolute; inset: 0; z-index: -1; }
.mnl[data-slot-bg] { background: transparent; }
.mnl-bg::after { content: ""; position: absolute; inset: 0; pointer-events: none; }
.mnl[data-tone="dark"] { --ds-fg: oklch(0.96 0.01 265); --ds-muted: oklch(0.84 0.02 265);
  --ds-border: oklch(0.98 0.01 265 / 0.2); color: var(--ds-fg); }
.mnl[data-tone="dark"] .mnl-bg::after {
  background: oklch(0.09 0.02 265 / calc(var(--mnl-scrim, 0.5) * 0.9)); }
.mnl[data-tone="light"] { --ds-fg: oklch(0.2 0.02 265); --ds-muted: oklch(0.42 0.02 265);
  --ds-border: oklch(0.2 0.02 265 / 0.22); color: var(--ds-fg); }
.mnl[data-tone="light"] .mnl-bg::after {
  background: oklch(0.99 0 0 / calc(var(--mnl-scrim, 0.5) * 0.9)); }
`;

export interface MenuItem {
  /** Группа меню (формат «Выпечка») — позиции с одинаковой группой собираются вместе. */
  group: string;
  name: string;
  /** Цена строкой вместе с валютой (формат «240 ₽»). */
  price: string;
  /** Строка под названием: состав, объём. */
  note?: string;
}

export interface MenuListProps {
  eyebrow?: string;
  title?: string;
  sub?: string;
  items?: MenuItem[];
  /** Сноска под меню: аллергены, часы кухни. */
  note?: string;
  /**
   * Сокет фона: любой эффект с `mount.role: "background"`, например `<AuroraField />`.
   * Занят — секция снимает собственную заливку, чтобы не спорить за слой.
   */
  background?: ReactNode;
  /** Какой текст читается поверх фона; приходит из `mount.tone` подставленной записи. */
  tone?: "dark" | "light";
  /** Плотность скрима между фоном и текстом; 0 — выключить. */
  scrim?: number;
}

const ITEMS: MenuItem[] = [
  { group: "Хлеб", name: "Тартин на закваске", price: "320 ₽", note: "пшеница и цельное зерно, 600 г" },
  { group: "Хлеб", name: "Багет утренний", price: "180 ₽", note: "хрустит до вечера, потом — гренки" },
  { group: "Хлеб", name: "Ржаной с тмином", price: "290 ₽" },
  { group: "Выпечка", name: "Круассан", price: "210 ₽", note: "78 слоёв, нормандское масло" },
  { group: "Выпечка", name: "Булочка с кардамоном", price: "190 ₽" },
  { group: "Выпечка", name: "Тарт с сезонной ягодой", price: "340 ₽", note: "что созрело — то и внутри" },
  { group: "Кофе", name: "Эспрессо", price: "160 ₽" },
  { group: "Кофе", name: "Капучино", price: "240 ₽", note: "220 мл, молоко можно овсяное" },
  { group: "Кофе", name: "Фильтр дня", price: "200 ₽", note: "спросите у бариста, что в воронке" },
];

/**
 * МЕНЮ с ценами — третий вариант вида pricing: у SaaS-тарифов колонки планов,
 * у локального бизнеса — группы позиций с точечной отбивкой до цены, как в
 * бумажном меню (пунктир рисует гибкий филлер с бордером — занимает ровно
 * остаток строки, никаких замеров).
 *
 * Данные НАМЕРЕННО плоские: одна позиция = {group, name, price, note}, группировка
 * происходит в рендере с сохранением порядка появления групп. Вложенные группы
 * (группа → список позиций) упёрлись бы в «карточку в карточке» — вложенных
 * списков схема слотов не умеет (открытый пункт BACKLOG), а плоский список
 * правится inline уже сегодня.
 */
export function MenuList({
  eyebrow = "меню",
  title = "Печём утром — к вечеру полки пустые",
  sub = "Всё из своей печи. Что закончилось — закончилось, приходите к открытию.",
  items = ITEMS,
  note = "Цены действуют на месте и с собой. Про аллергены спрашивайте — знаем состав каждой позиции.",
  background,
  tone,
  scrim = 0.5,
}: MenuListProps) {
  // порядок групп = порядок их первого появления в списке
  const groups: { name: string; rows: { item: MenuItem; idx: number }[] }[] = [];
  items.forEach((item, idx) => {
    const g = groups.find((x) => x.name === item.group);
    if (g) g.rows.push({ item, idx });
    else groups.push({ name: item.group, rows: [{ item, idx }] });
  });

  return (
    <section
      className="mnl"
      data-slot-bg={background ? "" : undefined}
      data-tone={tone}
      style={{ textAlign: "start", ["--mnl-scrim" as string]: scrim }}
    >
      <style href="motiva-menu-list" precedence="medium">{css}</style>

      {background && <div className="mnl-bg">{background}</div>}

      <div className="mnl-in">
        {/* data-slot: адрес слота для inline-правки в конструкторе (/build) */}
        {eyebrow && <p className="mnl-eyebrow" data-slot="eyebrow">{eyebrow}</p>}
        {title && <h2 className="mnl-h" data-slot="title">{title}</h2>}
        {sub && <p className="mnl-sub" data-slot="sub">{sub}</p>}

        <div className="mnl-groups">
          {groups.map((g) => (
            <section className="mnl-group" key={g.name}>
              <h3 className="mnl-gname" data-slot="items" data-idx={g.rows[0]!.idx} data-field="group">
                {g.name}
              </h3>
              <ul className="mnl-items">
                {g.rows.map(({ item, idx }) => (
                  <li className="mnl-item" key={item.name}>
                    <span className="mnl-row">
                      <span className="mnl-name" data-slot="items" data-idx={idx} data-field="name">{item.name}</span>
                      <span className="mnl-dots" aria-hidden="true" />
                      <span className="mnl-price" data-slot="items" data-idx={idx} data-field="price">{item.price}</span>
                    </span>
                    {item.note && (
                      <p className="mnl-note" data-slot="items" data-idx={idx} data-field="note">{item.note}</p>
                    )}
                  </li>
                ))}
              </ul>
            </section>
          ))}
        </div>

        {note && <p className="mnl-foot" data-slot="note">{note}</p>}
      </div>
    </section>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0