GrCarousel
Берут, когда галерея товара или объекта.
Обзор
Когда брать
- галерея товара или объекта — несколько снимков там, где место есть только под один: полоса миниатюр показывает, сколько ещё, и ведёт сразу к нужному;
- онбординг и промо-экраны — три-пять кадров подряд, которые пролистывают пальцем, а не выбирают по имени;
- лента карточек шире экрана — отзывы, подборки, связанные материалы: кадром здесь работает произвольная разметка, а не картинка;
- экран без зрителя — витрина или монитор в зале: показ идёт сам, а кнопка
паузы и
prefers-reduced-motionего останавливают.
Когда взять другое
| Нужно | Берите |
|---|---|
| Разделы с именами, между которыми переключаются | GrTabs + GrTabPanels |
| Рассмотреть картинку: масштаб, поворот, полный экран | GrImageViewer |
| Показать всё сразу, свернув лишнее по месту | GrCollapse |
| Вертикальный список, где видны все строки | GrList / GrDataTable |
| Порядок элементов меняет пользователь | GrSortableList |
| Этапы процесса, где важен статус каждого | GrSteps |
Варианты все тона и виды — настоящим компонентом
tone
primaryneutralsuccesswarningdangerinfoslateazure
Матрица живая: наводите и нажимайте. Состояний наведения, фокуса и нажатия здесь нет — они за браузером, а не за пропом.
Playground контролы выводятся из API, а не пишутся руками
Загружается…
0 изменено· состояние — в адресе
<GrCarousel ariaLabel="What Acme Billing does" indicators="dots">
<GrCarouselSlide label="Invoices"><div class="grid gap-1 p-6 text-center"><span class="font-600">Send invoices in two clicks</span><span class="text-[var(--gr-muted-fg)]">The PDF and the reminder go out by themselves.</span></div></GrCarouselSlide>
<GrCarouselSlide label="Payments"><div class="grid gap-1 p-6 text-center"><span class="font-600">Get paid by card or SEPA</span><span class="text-[var(--gr-muted-fg)]">Every invoice carries a payment link.</span></div></GrCarouselSlide>
<GrCarouselSlide label="Reports"><div class="grid gap-1 p-6 text-center"><span class="font-600">Know who owes what</span><span class="text-[var(--gr-muted-fg)]">Overdue invoices rise to the top.</span></div></GrCarouselSlide>
</GrCarousel>Примеры 4 у каждого — живое демо и его код
Снимки товара, которые следуют за отделкой
indicators="thumbnails"показывает, сколько снимков, и ведёт сразу к нужному; каждыйGrCarouselSlideотдаёт картинку черезthumbnail, а имя — черезlabel.v-model— индекс показанного кадра, поэтому кнопки отделки и галерея двигают друг друга.Ergonomic chairNorthwind Office · ships in 3 days€349Finish: OakОтзывы клиентов, которые листаются сами
autoplayподходит витрине: показ останавливается под курсором, в фокусе и при «уменьшить движение», а кнопка паузы есть всегда — её называютplay-labelиpause-label. Страница ещё и вызываетpause()иplay(), когда секция уходит с экрана и возвращается.landmarkсaria-labelledbyделает ленту регионом с именем из заголовка;activation-mode="manual"не даёт стрелкам на точках менять цитату под диктором. Слотыprevиnextменяют только иконки, аtone="slate"делает точки спокойными рядом с цитатой.What customers say «Reminders went out on their own, and our overdue invoices halved in the first quarter.»
GWGrace WuFinance lead, Northwind Traders «Setting up SEPA took an afternoon. Before, it took a bank visit and two weeks.»
OHOmar HaddadFounder, Tailspin Toys «Patients pay from the link in the email. We stopped printing invoices altogether.»
INInes NovakPractice manager, Harbour Street «The VAT report matches the books to the cent, so the quarter closes in a day.»
LRLinus RayAccountant, Contoso Ltd Тур по продукту со своими «Назад» и «Далее»
У тура есть начало и конец:
loopиarrowsвыключены, шаги ведут открытые методыprev()иnext(), а «1 of 3» берётcountу самих кадров. Точки —indicators="dots"с именем изindicators-label— по-прежнему ведут к любому шагу, а свайп листает.Send invoices in two clicksPick a customer, add the lines, send — the PDF and the reminder go out by themselves.Get paid by card or SEPAEvery invoice carries a payment link, and the money lands in your account in two days.Know who owes whatOverdue invoices rise to the top, and the cash forecast updates as payments arrive.1 of 3Новости релизов в боковой панели
orientation="vertical"нужна определённая высота — шаг считается от неё. Двадцать четыре релиза, ноvirtualсvirtual-overscanрисует только текущий и соседей. Вертикальный свайп в узкой панели спорил бы с прокруткой страницы, поэтомуswipeвыключен, а «Latest» вызываетgoTo(0).What's newv3.23 · October 2026Reverse-charge invoicesVAT notes on EU invoices are filled in for you.v3.22 · September 2026Payment links in remindersEvery reminder carries the link that settles it.
API порождается из пакета · разойтись не может
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
tone |
| undefined | Тон текущего переключателя по общей шкале пакета. |
modelValuev-model | number | undefined | undefined | Индекс текущего кадра. Не передан — лента ведёт позицию сама. |
ariaLabel | string | undefined | undefined | Имя карусели. APG требует его: `aria-roledescription` именем не считается. |
virtual | boolean | undefined | false | Не рисовать содержимое кадров вдали от текущего. Виртуализируется **нутро кадра, а не кадр**: кадры приходят слотом, и не отрисовать чужой узел лента не может. Пустая обёртка держит место в ряду и почти ничего не весит, а картинки и карточки из DOM уходят. |
orientation | "horizontal" | "vertical" | undefined | "horizontal" | Ось движения ленты. По вертикали вьюпорту нужна **определённая высота**: шаг считается от неё, и без высоты лента вырождается в столбец кадров. |
autoplay | boolean | undefined | false | Запустить показ. Под `prefers-reduced-motion` не стартует — тумблер остаётся. |
autoplayInterval | number | undefined | 5000 | Пауза между кадрами, мс. |
loop | boolean | undefined | true | Замкнуть ленту. Выключено — стрелки на краях гаснут. |
virtualOverscan | number | undefined | 1 | Сколько кадров по обе стороны от текущего рисовать сверх него. Меньше единицы ставить нельзя: следующий кадр обязан быть готов **до** перехода, иначе лента поедет на пустоту, а содержимое проявится уже на месте. |
indicators | "none" | "dots" | "thumbnails" | undefined | undefined | Вид переключателя кадров. `none` меняет и роль самих слайдов. |
activationMode | "automatic" | "manual" | undefined | undefined | Листает ли стрелка по переключателям сразу или только двигает фокус. |
arrows | boolean | undefined | true | Стрелки «назад/вперёд». |
swipe | boolean | undefined | true | Листание протяжкой указателя. |
landmark | boolean | undefined | false | Сделать карусель ориентиром страницы (`role="region"`). |
ariaLabelledby | string | undefined | undefined | Имя из заголовка на странице. Сильнее `ariaLabel`. |
prevLabel | string | undefined | undefined | Доступное имя стрелки «назад»: у кнопки только иконка. По умолчанию — строка локали. |
nextLabel | string | undefined | undefined | Доступное имя стрелки «вперёд»: у кнопки только иконка. По умолчанию — строка локали. |
playLabel | string | undefined | undefined | Имя тумблера показа, пока лента стоит. По умолчанию — строка локали. |
pauseLabel | string | undefined | undefined | Имя тумблера показа, пока кадры листаются сами. По умолчанию — строка локали. |
indicatorsLabel | string | undefined | undefined | Имя полосы переключателей. |
Слоты
| Slot | Type | Описание |
|---|---|---|
default | any | Кадры ленты — `GrCarouselSlide`. |
prev | { disabled: boolean; } | Своя иконка кнопки «назад». Заменяет содержимое кнопки, но не её саму: имя, `aria-disabled` и поведение на краю остаются за компонентом. |
next | { disabled: boolean; } | Своя иконка кнопки «вперёд». |
События
| Event | Сигнатура | Описание |
|---|---|---|
update:modelValue | (event: "update:modelValue", index: number): void | — |
Методы
| Methods | Сигнатура | Описание |
|---|---|---|
index | number | Текущий кадр — единственный способ прочитать позицию без `v-model`. |
count | number | Число кадров: состав живёт в слоте, снаружи его посчитать нечем. |
playing | boolean | Идёт ли показ. Пауза под курсором его не выключает. |
next | () => void | Следующий кадр. Останавливает показ; без `loop` на последнем кадре ничего не делает. |
prev | () => void | Предыдущий кадр. Останавливает показ; без `loop` на первом кадре ничего не делает. |
goTo | (index: number) => void | Перейти к кадру по индексу; индекс за пределами ленты прижимается к краю. Останавливает показ. |
play | () => void | Запустить показ — так же, как тумблером. |
pause | () => void | Остановить показ и объявить остановку скринридеру. |
Типы
Все экспортируемые типы подпути, по файлам, где они объявлены. Имена, дженерики и тела читаются из файлов деклараций пакета и разойтись не могут.
import type { GrCarouselConfigurableProps, GrCarouselProps, GrCarouselEmits } from '@feugene/granularity/components/GrCarousel'Файлdist/types/components/GrCarousel/config.d.ts
dependencies пуст намеренно: стрелки и тумблер — обычные <button>, а не
GrButton. Тот связывает disabled с нативным атрибутом, а стрелка на краю
без loop обязана остаться фокусируемой (aria-disabled), иначе фокус
падает в <body> ровно в момент, когда пользователь долистал до конца.
const grCarouselConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrCarousel">;Файлdist/types/components/GrCarousel/defaults.d.ts
Пропы GrCarousel, настраиваемые глобально через componentDefaults.
interface GrCarouselConfigurableProps {
indicators: GrCarouselIndicators;
activationMode: GrCarouselActivationMode;
tone: GrTone;
}Файлdist/types/components/GrCarousel/GrCarousel.vue.d.ts
interface GrCarouselProps {
/** Индекс текущего кадра. Не передан — лента ведёт позицию сама. */
modelValue?: number;
/** Запустить показ. Под `prefers-reduced-motion` не стартует — тумблер остаётся. */
autoplay?: boolean;
/** Пауза между кадрами, мс. */
autoplayInterval?: number;
/** Замкнуть ленту. Выключено — стрелки на краях гаснут. */
loop?: boolean;
/**
* Ось движения ленты. По вертикали вьюпорту нужна **определённая высота**:
* шаг считается от неё, и без высоты лента вырождается в столбец кадров.
*/
orientation?: GrCarouselOrientation;
/**
* Не рисовать содержимое кадров вдали от текущего.
*
* Виртуализируется **нутро кадра, а не кадр**: кадры приходят слотом, и не
* отрисовать чужой узел лента не может. Пустая обёртка держит место в ряду и
* почти ничего не весит, а картинки и карточки из DOM уходят.
*/
virtual?: boolean;
/**
* Сколько кадров по обе стороны от текущего рисовать сверх него. Меньше
* единицы ставить нельзя: следующий кадр обязан быть готов **до** перехода,
* иначе лента поедет на пустоту, а содержимое проявится уже на месте.
*/
virtualOverscan?: number;
/** Вид переключателя кадров. `none` меняет и роль самих слайдов. */
indicators?: GrCarouselIndicators;
/** Тон текущего переключателя по общей шкале пакета. */
tone?: GrTone;
/** Листает ли стрелка по переключателям сразу или только двигает фокус. */
activationMode?: GrCarouselActivationMode;
/** Стрелки «назад/вперёд». */
arrows?: boolean;
/** Листание протяжкой указателя. */
swipe?: boolean;
/** Сделать карусель ориентиром страницы (`role="region"`). */
landmark?: boolean;
/** Имя карусели. APG требует его: `aria-roledescription` именем не считается. */
ariaLabel?: string;
/** Имя из заголовка на странице. Сильнее `ariaLabel`. */
ariaLabelledby?: string;
/** Доступное имя стрелки «назад»: у кнопки только иконка. По умолчанию — строка локали. */
prevLabel?: string;
/** Доступное имя стрелки «вперёд»: у кнопки только иконка. По умолчанию — строка локали. */
nextLabel?: string;
/** Имя тумблера показа, пока лента стоит. По умолчанию — строка локали. */
playLabel?: string;
/** Имя тумблера показа, пока кадры листаются сами. По умолчанию — строка локали. */
pauseLabel?: string;
/** Имя полосы переключателей. */
indicatorsLabel?: string;
}interface GrCarouselEmits {
/** Сменился текущий кадр (`v-model`): стрелкой, переключателем, протяжкой или показом. */
(e: 'update:modelValue', index: number): void;
}Файлdist/types/components/GrCarousel/GrCarouselSlide.vue.d.ts
interface GrCarouselSlideProps {
/** Имя слайда: подпись его переключателя и доступное имя самого кадра. */
label?: string;
/** URL миниатюры для `indicators="thumbnails"`. Слот `#thumbnail` сильнее. */
thumbnail?: string;
}Файлdist/types/components/GrCarousel/grCarouselStyles.d.ts
type GrCarouselIndicators = typeof GR_CAROUSEL_INDICATORS[number];type GrCarouselActivationMode = typeof GR_CAROUSEL_ACTIVATION_MODES[number];Файлdist/types/components/GrCarousel/safelist.d.ts
Только текущий переключатель: его классы склеиваются в рантайме из тона и
скобок arbitrary() (grCarouselStyles.ts: grCarouselDotActiveClass —
заливка, обвод и его отступ; grCarouselThumbActiveClass — рамка миниатюры),
и целиком их нет ни в одной строке кода. Тон приходит пропом, поэтому
объявлена вся шкала. Остальное компонент берёт литералами, и granum извлекает
это из его чанков сам, включая общие.
const grCarouselSafelist: string[];Файлdist/types/components/GrCarousel/index.d.ts
type GrCarouselInstance = ComponentExposed<typeof GrCarouselComponent>;Доступность
Паттерн APG
carousel
Клавиши
Home / End
| Клавиша | Действие |
|---|---|
| — | Своих клавиш у ленты нет: она не таб-стоп и не скроллер — кадр двигает трансформ. Кольцо живёт у полосы переключателей: ←/→ — по кадрам циклично (независимо от loop) |
| Home / End | к первому и последнему, полоса держит одну остановку Tab. При activationMode="manual" стрелка двигает только фокус, кадр подтверждает Enter/Space. Стрелки листания и кнопка паузы — обычные кнопки; на краю без loop стрелка объявлена aria-disabled, но остаётся фокусируемой. Фокус, вошедший в карусель, останавливает показ и сам его не возобновляет |
Полный контракт пакета: docs/keyboard.md ↗
Использование из прозы библиотеки
Кольцо замыкается без отката
loop включён по умолчанию, и раньше он просто сбрасывал индекс: лента
прокручивалась назад через все кадры. На десятикадровой карусели с
автопрокруткой каждый десятый шаг был длинным обратным ходом.
Теперь на границе кольца крайний кадр временно переезжает на противоположный конец, лента доезжает до него как до обычного соседа, и уже после перехода позиция снимается без анимации. Клона тут нет и быть не может: кадры приходят слотом, а копия чужого узла унесла бы с собой его состояние и ссылки.
Модель обновляется сразу, не дожидаясь конца перехода: modelValue про то,
какой кадр показан, а не про то, доехала ли лента.
Ось движения
<GrCarousel orientation="vertical" class="h-56">
<GrCarouselSlide label="Ноябрь">…</GrCarouselSlide>
</GrCarousel>Вертикальной ленте нужна определённая высота вьюпорта. Шаг считается от неё, и без высоты колонка кадров растёт по содержимому, а «на один кадр» означает всю ленту разом. Высота нулевая — компонент говорит об этом в dev-сборке, а не молча рисует столбец.
Стрелки переезжают на ось движения, поперечная прокрутка страницы остаётся за
страницей (touch-action), а полоса переключателей остаётся горизонтальной:
она про выбор кадра, а не про направление ленты.
Виртуализация
<GrCarousel virtual :virtual-overscan="1">
<GrCarouselSlide label="Кадр">…</GrCarouselSlide>
</GrCarousel>Виртуализируется нутро кадра, а не кадр. Кадры приходят слотом, и не отрисовать чужой узел лента не может — но может попросить его не рисовать своё содержимое. Пустая обёртка держит место в ряду и почти ничего не весит, а картинки и карточки уходят из DOM.
virtualOverscan — сколько кадров по обе стороны рисовать сверх текущего.
Меньше единицы не бывает: следующий кадр обязан быть готов до перехода,
иначе лента поедет на пустоту, а содержимое проявится уже на месте. При loop
окно замкнуто вместе с лентой — соседи первого кадра слева это последние.
Кадры приходят слотом, а не пропом items
Разметка кадра у каждого своя: у галереи это <img>, у онбординга — заголовок,
абзац и кнопка, у ленты отзывов — карточка. Массив данных заставил бы либо
render-функцию, либо шаблон-проп на каждый вид кадра, поэтому кадр — это
GrCarouselSlide с произвольным содержимым внутри.
Следствие, о котором стоит знать: состав ленты компонент узнаёт от самих кадров, при монтировании. На сервере полоса переключателей выходит пустой и заполняется следующим тактом на клиенте — первый клиентский рендер повторяет серверный, и гидрация цела.
Это карусель, а не tablist
Корень — role="group" (или region при landmark) с
aria-roledescription="carousel". Имени по умолчанию нет намеренно:
aria-roledescription именем не считается, а подставленное «карусель» диктор
прочитал бы дважды подряд. Поэтому ariaLabel или ariaLabelledby обязательны,
и без них компонент предупреждает в dev.
Роль кадра зависит от наличия переключателя, и только от него: с полосой
кадр — tabpanel, привязанный к своей вкладке, без полосы — group с именем
«N из M». Вид переключателя на роли не сказывается: точки и миниатюры — один и
тот же tablist в двух шкурках, иначе косметический проп переписывал бы
контракт доступности.
Кадр, который сейчас не показан, помечен inert. Это не полировка: кадры лежат
в одном ряду и обрезаются по вьюпорту, поэтому без него Tab уезжает в
невидимый кадр, а браузер прокручивает к нему контейнер и разъезжается с лентой.
Своих клавиш у ленты нет
Лента не фокусируется и скроллером не является — сдвиг делает трансформ, а не
прокрутка. Кольцо стрелок живёт у полосы переключателей: ←/→ ходят по
кадрам, Home/End — к краям, полоса держит одну остановку Tab. При
activationMode="manual" стрелка двигает только фокус, а кадр подтверждает
Enter или Space.
Кольцо полосы замкнуто всегда, независимо от loop: loop управляет стрелками
и показом, а выбрать любой кадр напрямую можно в любом случае.
Показ останавливается сам
Курсор над лентой, идущий жест и вкладка в фоне ставят отсчёт на паузу: остаток сохраняется, и по возвращении лента досчитывает его, а не начинает интервал заново. Фокус, вошедший внутрь, показ останавливает и сам его не возобновляет — иначе кадр уехал бы из-под пальца, пока пользователь читает ссылку внутри него.
Кнопка паузы рисуется всегда, когда задан autoplay, и спрятать её нечем: APG
запрещает движущуюся карусель без механизма остановки.
Под prefers-reduced-motion показ не стартует, но кнопка остаётся и предлагает
запустить его — «уменьшить движение» не значит «отнять возможность». Глобальный
кламп здесь не помогает: он гасит CSS, а отсчёт ведёт таймер, и лента продолжала
бы ехать у того, кто просил её этого не делать.
Свайп не отменяет нажатие
Жест начинается только там, где под указателем нет ссылки, кнопки или поля: интерактив внутри кадра остаётся интерактивом. Дальше жест обязан превысить порог — больший из 60 px и 15 % ширины ленты — и идти горизонтальнее, чем вертикально, иначе это прокрутка страницы, и карусель её не перехватывает. Вертикальная прокрутка страницы остаётся браузеру: лента гасит только горизонталь.
Обрыв жеста кадр не листает. Указатель у страницы забирает браузер — системный жест, звонок, потеря окна, — и пользователь при этом ничего не доводил.
Один кадр и пустая лента — состояния, а не крайние случаи
С одним кадром стрелки, точки и миниатюры не рисуются вовсе, а не рисуются
выключенными: выключенный орган управления сообщает, что идти есть куда.
Пустая лента — это v-if на стороне потребителя: кадры пишет он, и подставлять
за него текст «ничего нет» компонент не станет.
Миниатюра берётся из трёх мест
По убыванию силы: слот #thumbnail у кадра, затем его проп thumbnail с URL,
затем номер кадра в плитке того же размера. Третий вариант держит ритм полосы,
когда картинки нет вовсе; если миниатюру не дал ни один кадр при
indicators="thumbnails", компонент предупреждает в dev.
Первую <img> внутри кадра компонент не ищет: обход разметки не работает на
сервере, ломается на кадрах без картинок и прячет контракт от читателя.
Свои иконки стрелок и тон переключателей
Слоты #prev и #next заменяют содержимое кнопки, а не саму кнопку: имя,
aria-disabled и поведение на краю остаются за компонентом. Иначе своя стрелка
приносила бы с собой и обязанность повторить весь контракт — а его чаще всего
не повторяют.
Тон текущего переключателя берётся из общей шкалы пакета (tone), а не из
своего набора цветов. Точечная перекраска — по-прежнему через
--gr-carousel-dot-active: проп задаёт роль, хук задаёт значение. У
neutral насыщенного цвета нет, и текущая точка (и рамка текущей миниатюры) у
него цвета основного текста, --gr-fg, — как нейтральный счётчик
GrBadgeWrap. Серый поверхности тут не годится: им нарисованы неактивные точки.
Текущая точка несёт два признака: заливку и обвод вокруг неё. Одной заливки мало — при диаметре в восемь пикселей она отличала точку только оттенком, и на приглушённом тоне разница почти пропадала. Обвод добавляет форму, то есть работает и там, где цвет не различают.
Полоса миниатюр двигает себя, а не страницу
Прокрутка к текущей миниатюре считается арифметикой и присваивается scrollLeft
самой полосы. scrollIntoView здесь не годится: он двигает всех предков,
включая документ, — и карусель, уехавшая под сгиб, на каждом шаге автопрокрутки
утаскивала бы страницу обратно к себе.
Границы
- виртуализации нет — все кадры лежат в DOM. Лента на сотни тяжёлых кадров требует своего решения на стороне потребителя;
- бесконечной ленты клонированием нет —
loopперематывает индекс, поэтому на переходе с последнего кадра на первый лента едет назад, а не продолжает движение; - вертикальной ориентации нет;
- стрелки лежат поверх кадра — они рассчитаны на снимок, где перекрыть нечего. Кадр с текстом обязан развести содержимое с ними полями по краям (кнопка 36 px плюс отступ 8 px с каждой стороны), иначе первые буквы строки уходят под кнопку. Вынести стрелки за пределы ленты компонент не умеет;
- высоту задаёт самый высокий кадр — кадры лежат в одном ряду. Постоянная высота, если она нужна, задаётся содержимым кадра.
Пропы этого примера