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 в корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.
  2. Алиас @ объявлен дважды: "paths": {"@/*": ["./src/*"]} в корневом tsconfig.json (в Vite он часто содержит только references — тогда CLI создаёт папку@) и resolve.alias в конфиге сборщика.
  3. Токены подключены один раз на приложение:import "@/styles/motiva-tokens.css" — они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему.
  4. Своя типографика не должна спорить: если у вас на обёрткеtext-align: center, он протечёт внутрь блоков.
  5. Проект без TypeScript? Всё равно нужен минимальныйtsconfig.json с pathsи "tsx": true в components.json: CLI грузит tsconfig безусловно, а с "tsx": false идёт за jsconfig.json и падает так же. Приехавший рядом.tsx можно не трогать — вам нужен.vanilla.js.

Экспорт

↓ Скачать .zip

Код

"use client";

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.5»); 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>{`
        .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)); }
      `}</style>

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

      <div className="faq-in">
        {eyebrow && <p className="faq-eyebrow">{eyebrow}</p>}
        <h2 className="faq-h">{title}</h2>

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