← каталог
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? | boolean | true | Открывать только один пункт за раз. |
| background? | ReactNode | — | Сокет фона: любой эффект с `mount.role: "background"`, например `<AuroraField />`. Занят — секция снимает собственную заливку, чтобы не спорить за слой. |
FaqItem — что внутри
- q: string
- a: string
Установка
npx shadcn@latest add https://motiva.pages.dev/r/faq-accordion.jsonЧто нужно в проекте до установки
components.jsonв корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.- Алиас
@объявлен дважды:"paths": {"@/*": ["./src/*"]}в корневомtsconfig.json(в Vite он часто содержит толькоreferences— тогда CLI создаёт папку@) иresolve.aliasв конфиге сборщика. - Токены подключены один раз на приложение:
import "@/styles/motiva-tokens.css"— они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему. - Своя типографика не должна спорить: если у вас на обёртке
text-align: center, он протечёт внутрь блоков. - Проект без TypeScript? Всё равно нужен минимальный
tsconfig.jsonсpathsи"tsx": trueвcomponents.json: CLI грузит tsconfig безусловно, а с"tsx": falseидёт заjsconfig.jsonи падает так же. Приехавший рядом.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"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