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

FAQ Accordion

Аккордеон на нативных details: клавиатура, скринридер и поиск по странице бесплатно. Плавное раскрытие — grid-template-rows 0fr→1fr, единственный способ анимировать высоту неизвестного контента без замеров.

cssclickrevealintermediate~2 КБreduced-motion ✓
↗

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

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

ПропЧто этоТип
eyebrowНадпись сверхуtext
titleЗаголовокtext
itemsВопросыlist · до 10

Все пропсы

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

ПропТипПо умолчаниюЧто это
eyebrow?string"вопросы"
title?string"Коротко о том, как мы работаем"
items?FaqItem[]ITEMS
single?booleantrueОткрывать только один пункт за раз.
background?ReactNode—Сокет фона: любой эффект с `mount.role: "background"`, например `<AuroraField />`. Занят — секция снимает собственную заливку, чтобы не спорить за слой.
FaqItem — что внутри
  • q: string
  • a: string

Установка

npx shadcn@latest add https://motiva.pages.dev/r/faq-accordion.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

Код

"use client";

const css = `.faq { 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);
  /* наследуется вниз — включает интерполяцию к auto для всего аккордеона */
  interpolate-size: allow-keywords; }
.faq-in { max-width: min(var(--ds-container, 1120px), 780px); margin-inline: auto;
  padding-inline: var(--ds-gutter, clamp(18px, 5vw, 54px)); }
.faq-eyebrow { margin: 0 0 var(--ds-2, 8px);
  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)); }
.faq-h { margin: 0 0 var(--ds-6, 36px); font-size: var(--ds-text-xl, 2rem); font-weight: 750;
  letter-spacing: var(--ds-tracking-tight, -0.03em); }

.faq-item { position: relative; border-bottom: 1px solid var(--ds-border, oklch(0.3 0.02 275)); }
/* акцентная рейка слева: только scaleY, поэтому ничего не перерисовывается */
.faq-item::before { content: ""; position: absolute; left: calc(-1 * var(--ds-3, 12px));
  top: var(--ds-3, 12px); bottom: var(--ds-3, 12px); width: 2px; border-radius: 2px;
  background: var(--ds-accent, oklch(0.72 0.16 300));
  scale: 1 0; transform-origin: 50% 0;
  transition: scale var(--ds-base, 0.34s) var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)); }
.faq-item[open]::before { scale: 1 1; }

.faq-item summary { list-style: none; cursor: pointer; display: flex; gap: var(--ds-4, 16px);
  align-items: center; justify-content: space-between;
  padding: var(--ds-4, 16px) 0; font-size: var(--ds-text-md, 1rem); font-weight: 600;
  transition: color var(--ds-fast, 0.18s); }
.faq-item summary::-webkit-details-marker { display: none; }
.faq-item summary:hover { color: var(--ds-accent, oklch(0.72 0.16 300)); }
.faq-item[open] summary { color: var(--ds-accent, oklch(0.72 0.16 300)); }
.faq-item summary:focus-visible { outline: 2px solid var(--ds-accent-ring, oklch(0.72 0.16 300 / 0.35));
  outline-offset: 4px; border-radius: var(--ds-radius-sm, 8px); }
/* заголовок чуть отъезжает вправо — движение подсказывает, что пункт «вышел вперёд» */
.faq-q { transition: translate var(--ds-base, 0.34s) var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)); }
.faq-item[open] .faq-q { translate: var(--ds-1, 4px) 0; }

/* плюс → минус: контейнер доворачивается, вертикальная палочка схлопывается */
.faq-sign { flex: none; width: 20px; height: 20px; position: relative;
  color: var(--ds-muted, oklch(0.72 0.02 265));
  transition: rotate var(--ds-base, 0.34s) var(--ds-ease-in-out, cubic-bezier(0.65, 0, 0.35, 1)),
              color var(--ds-fast, 0.18s); }
.faq-item[open] .faq-sign { rotate: 180deg; color: var(--ds-accent, oklch(0.72 0.16 300)); }
.faq-sign::before, .faq-sign::after { content: ""; position: absolute; inset: 50% 0 auto;
  height: 2px; margin-top: -1px; border-radius: 2px; background: currentColor; }
.faq-sign::after { rotate: 90deg;
  transition: scale var(--ds-base, 0.34s) var(--ds-ease-in-out, cubic-bezier(0.65, 0, 0.35, 1)); }
.faq-item[open] .faq-sign::after { scale: 0 1; }

/* ВОТ ЗДЕСЬ раскрытие: псевдоэлемент содержимого живёт в обоих состояниях */
.faq-item::details-content {
  block-size: 0; overflow: clip;
  transition: block-size var(--ds-base, 0.34s) var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)),
              content-visibility var(--ds-base, 0.34s) allow-discrete; }
.faq-item[open]::details-content { block-size: auto; }

/* текст выезжает отдельной анимацией: ей состояние рендера не нужно,
   поэтому она отработает даже там, где ::details-content не поддержан */
.faq-a { margin: 0; padding: 0 0 var(--ds-5, 24px); max-width: 62ch;
  font-size: var(--ds-text-sm, 0.9rem); line-height: var(--ds-leading-body, 1.55);
  color: var(--ds-muted, oklch(0.72 0.02 265)); }
.faq-item[open] .faq-a {
  animation: faq-rise 0.45s 0.06s var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)) both; }
@keyframes faq-rise { from { opacity: 0; transform: translateY(-8px); } }

@media (prefers-reduced-motion: reduce) {
  .faq-item::details-content, .faq-item::before, .faq-q, .faq-sign, .faq-sign::after,
  .faq-item summary { transition: none; }
  .faq-item[open] .faq-a { animation: none; }
}
html[data-shot] .faq-item[open] .faq-a { animation: none; }

/* СОКЕТ ФОНА. Слой лежит на z-index -1 внутри своего stacking context: он рисуется
   поверх заливки секции, но под всем содержимым — поэтому контенту не нужно ни
   position, ни z-index, и вёрстка секции не меняется вовсе.
   Тон переопределяет ТОКЕНЫ, а не перечисляет классы: все дети и так читают
   --ds-fg/--ds-muted, значит пара фон+текст чинится в одном месте.
   Скрим обязателен: у живого фона яркость гуляет по кадру, и цвет текста, читаемый
   и на провале, и на вспышке, подобрать нельзя — контраст надо создавать. */
.faq { position: relative; isolation: isolate; }
.faq-bg { position: absolute; inset: 0; z-index: -1; }
.faq[data-slot-bg] { background: transparent; }
.faq-bg::after { content: ""; position: absolute; inset: 0; pointer-events: none; }
.faq[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.16); color: var(--ds-fg); }
.faq[data-tone="dark"] .faq-bg::after {
  background: oklch(0.09 0.02 265 / calc(var(--faq-scrim, 0.5) * 0.9)); }
.faq[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.14); color: var(--ds-fg); }
.faq[data-tone="light"] .faq-bg::after {
  background: oklch(0.99 0 0 / calc(var(--faq-scrim, 0.5) * 0.9)); }
`;
import type { ReactNode } from "react";
import { useId } from "react";

export interface FaqItem {
  q: string;
  a: string;
}

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

const ITEMS: FaqItem[] = [
  { q: "Сколько занимает проект?", a: "Лендинг — 2–3 недели, сайт — 4–6. Срок фиксируем в договоре вместе с объёмом: если он меняется, вы узнаёте об этом до начала работ, а не в конце." },
  { q: "Как устроена оплата?", a: "Три части: 40% на старте, 30% после утверждения дизайна, 30% на релизе. Промежуточные результаты остаются у вас в любом случае." },
  { q: "Кто пишет тексты?", a: "По умолчанию — вы, мы даём структуру и объёмы. Можем взять на себя: это отдельная строка в смете и +1 неделя к сроку." },
  { q: "Что с поддержкой после запуска?", a: "Месяц входит в стоимость: правки контента, мелкие доработки, помощь команде. Дальше — по часам или пакетом." },
  { q: "Отдаёте исходники?", a: "Да, всё: репозиторий, макеты, дизайн-систему. Никакого вендор-лока — проект можно передать другой команде и он соберётся." },
];

/**
 * Аккордеон вопросов на нативных `<details>` — с настоящим раскрытием, а не с подменой.
 *
 * Почему `<details>`: бесплатно достаются клавиатура, роль для скринридера, поиск по странице
 * внутри свёрнутого текста и работа без JS. Расплата ровно одна — анимировать его долго
 * было нечем, и вот почему.
 *
 * ГРАБЛЯ, из-за которой «анимация» обычно не работает: пока `<details>` закрыт, содержимое
 * НЕ отрендерено. Классический приём с `grid-template-rows: 0fr → 1fr` на обёртке внутри
 * тихо не срабатывает — переходу не от чего стартовать, потому что в момент открытия
 * элемент только появляется. Замер это подтверждает: высота встаёт в конечное значение
 * на первом же кадре и больше не меняется.
 *
 * Работающее решение — псевдоэлемент `::details-content`, то есть сам контейнер содержимого,
 * который живёт в обоих состояниях:
 *
 *   - `block-size: 0 → auto` даёт настоящее раскрытие. `auto` интерполируется только при
 *     `interpolate-size: allow-keywords` — свойство наследуется, поэтому объявлено один раз
 *     на секции;
 *   - `content-visibility` с `allow-discrete` держит содержимое видимым, пока идёт схлопывание.
 *     Без него закрытие мгновенное: браузер прячет контент на первом кадре.
 *
 * Где `::details-content` не поддержан — раскрытие просто мгновенное, а текст всё равно
 * выезжает (это отдельная CSS-анимация, ей рендер-состояние не нужно). Ветку писать не надо.
 */
export function FaqAccordion({
  eyebrow = "вопросы",
  title = "Коротко о том, как мы работаем",
  items = ITEMS,
  single = true,
  background,
  tone,
  scrim = 0.5,
}: FaqAccordionProps) {
  // имя группы уникально на экземпляр: две секции FAQ на одной странице иначе делили бы
  // одну группу и закрывали пункты друг у друга
  const group = useId();

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

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

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

        {items.map((it, i) => (
          // общий name делает группу «только один открыт» — тоже без единой строчки JS
          <details key={it.q} className="faq-item" name={single ? group : undefined} open={i === 0}>
            <summary>
              {/* data-slot + data-idx + data-field: адрес поля пункта для inline-правки
                  в конструкторе (/build). */}
              <span className="faq-q" data-slot="items" data-idx={i} data-field="q">{it.q}</span>
              <span className="faq-sign" aria-hidden />
            </summary>
            <p className="faq-a" data-slot="items" data-idx={i} data-field="a">{it.a}</p>
          </details>
        ))}
      </div>
    </section>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0