GrCarousel
Shows a set of slides one screen at a time — swipe, arrows, dots and thumbnails, with autoplay you can pause.
Overview
When to take it
- a gallery of a product or an object — several shots where there is room for only one: the strip of thumbnails shows how many more there are and leads straight to the right one;
- onboarding and promo screens — three to five slides in a row that are flicked through with a finger rather than chosen by name;
- a strip of cards wider than the screen — reviews, selections, related materials: the slide here is arbitrary markup rather than a picture;
- a screen without a viewer — a shop window or a monitor in a hall: the show
runs by itself, and the pause button and
prefers-reduced-motionstop it.
When to take something else
| Need | Take |
|---|---|
| Named sections that are switched between | GrTabs + GrTabPanels |
| Examine a picture: zoom, rotation, full screen | GrImageViewer |
| Show everything at once, folding the excess in place | GrCollapse |
| A vertical list where all the rows are visible | GrList / GrDataTable |
| The user changes the order of the items | GrSortableList |
| Stages of a process where the status of each matters | GrSteps |
Variants every tone and view, rendered by the real component
tone
primaryneutralsuccesswarningdangerinfoslateazure
The grid is live: hover and press it. Hover, focus and active states are not drawn here — they belong to the browser, not to a prop.
Playground controls come from the API, not from hand
Loading…
0 changed· state is in the URL
<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>Examples 4 each with a live demo and its code
Product photos that follow the finish
indicators="thumbnails"shows how many shots there are and leads straight to one; eachGrCarouselSlidegives its picture throughthumbnailand its name throughlabel.v-modelis the index of the shown slide, so the finish buttons and the gallery move each other.Ergonomic chairNorthwind Office · ships in 3 days€349Finish: OakCustomer reviews that play by themselves
autoplaysuits a showcase: it stops on hover, on focus and under reduced motion, and the pause button is always there —play-labelandpause-labelname it. The page also callspause()andplay()as the section leaves and enters the screen.landmarkwitharia-labelledbymakes the strip a region named by its heading;activation-mode="manual"keeps arrow keys on the dots from changing the quote under a screen reader. Theprevandnextslots swap only the icons, andtone="slate"keeps the dots quiet next to a quote.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 A product tour with its own Back and Next
A tour has a beginning and an end:
loopis off andarrowsare off, the steps are driven by the exposedprev()andnext(), and «1 of 3» readscountfrom the slides themselves. The dots —indicators="dots", named byindicators-label— still lead to any step, and a swipe still turns the page.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 3Release notes in a side panel
orientation="vertical"needs a fixed height — the step is measured from it. Twenty-four releases, butvirtualwithvirtual-overscandraws only the current one and its neighbours. A vertical swipe would fight the page scroll in a narrow panel, soswipeis off, and «Latest» callsgoTo(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 generated from the package · cannot drift
Props
| Prop | Type | default | Description |
|---|---|---|---|
tone |
| undefined | The tone of the current switcher by the shared scale of the package. |
modelValuev-model | number | undefined | undefined | The index of the current frame. Not passed — the strip keeps the position itself. |
ariaLabel | string | undefined | undefined | The name of the carousel. The APG requires it: `aria-roledescription` does not count as a name. |
virtual | boolean | undefined | false | Do not draw the content of the frames far from the current one. What is virtualised is **the inside of the frame, not the frame**: the frames come through the slot, and the strip cannot not render someone else’s node. An empty wrapper holds its place in the row and weighs almost nothing, while the pictures and cards leave the DOM. |
orientation | "horizontal" | "vertical" | undefined | "horizontal" | The axis the strip moves along. Vertically the viewport needs a **definite height**: the step is computed from it, and without a height the strip degenerates into a column of frames. |
autoplay | boolean | undefined | false | Start the show. Under `prefers-reduced-motion` it does not start — the toggle stays. |
autoplayInterval | number | undefined | 5000 | The pause between frames, ms. |
loop | boolean | undefined | true | Close the strip into a loop. Off — the arrows at the edges go dim. |
virtualOverscan | number | undefined | 1 | How many frames on either side of the current one to draw beyond it. It cannot be set below one: the next frame is obliged to be ready **before** the move, otherwise the strip would slide onto emptiness, and the content would appear once already in place. |
indicators | "none" | "dots" | "thumbnails" | undefined | undefined | The kind of frame switcher. `none` also changes the role of the slides themselves. |
activationMode | "automatic" | "manual" | undefined | undefined | Whether an arrow over the switchers flips the frame at once or only moves the focus. |
arrows | boolean | undefined | true | The "back/forward" arrows. |
swipe | boolean | undefined | true | Flipping by dragging the pointer. |
landmark | boolean | undefined | false | Make the carousel a landmark of the page (`role="region"`). |
ariaLabelledby | string | undefined | undefined | The name from a heading on the page. Stronger than `ariaLabel`. |
prevLabel | string | undefined | undefined | The accessible name of the "back" arrow: the button has only an icon. By default — a locale string. |
nextLabel | string | undefined | undefined | The accessible name of the "forward" arrow: the button has only an icon. By default — a locale string. |
playLabel | string | undefined | undefined | The name of the show toggle while the strip is still. By default — a locale string. |
pauseLabel | string | undefined | undefined | The name of the show toggle while the frames flip on their own. By default — a locale string. |
indicatorsLabel | string | undefined | undefined | The name of the strip of switchers. |
Slots
| Slot | Type | Description |
|---|---|---|
default | any | The frames of the strip — `GrCarouselSlide`. |
prev | { disabled: boolean; } | An icon of your own for the "back" button. It replaces the content of the button but not the button itself: the name, `aria-disabled` and the behaviour at the edge stay with the component. |
next | { disabled: boolean; } | An icon of your own for the "forward" button. |
Events
| Event | Signature | Description |
|---|---|---|
update:modelValue | (event: "update:modelValue", index: number): void | — |
Methods
| Methods | Signature | Description |
|---|---|---|
index | number | The current frame — the only way to read the position without `v-model`. |
count | number | The number of frames: the set lives in the slot, and there is nothing outside to count it with. |
playing | boolean | Whether the show is running. The pause under the cursor does not switch it off. |
next | () => void | The next frame. Stops the show; without `loop` it does nothing on the last frame. |
prev | () => void | The previous frame. Stops the show; without `loop` it does nothing on the first frame. |
goTo | (index: number) => void | Go to a frame by index; an index beyond the strip is clamped to the edge. Stops the show. |
play | () => void | Start the show — the same as with the toggle. |
pause | () => void | Stop the show and announce the stop to a screen reader. |
Types
Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.
import type { GrCarouselConfigurableProps, GrCarouselProps, GrCarouselEmits } from '@feugene/granularity/components/GrCarousel'Filedist/types/components/GrCarousel/config.d.ts
const grCarouselConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrCarousel">;Filedist/types/components/GrCarousel/defaults.d.ts
interface GrCarouselConfigurableProps {
indicators: GrCarouselIndicators;
activationMode: GrCarouselActivationMode;
tone: GrTone;
}Filedist/types/components/GrCarousel/GrCarousel.vue.d.ts
interface GrCarouselProps {
modelValue?: number;
autoplay?: boolean;
autoplayInterval?: number;
loop?: boolean;
orientation?: GrCarouselOrientation;
virtual?: boolean;
virtualOverscan?: number;
indicators?: GrCarouselIndicators;
tone?: GrTone;
activationMode?: GrCarouselActivationMode;
arrows?: boolean;
swipe?: boolean;
landmark?: boolean;
ariaLabel?: string;
ariaLabelledby?: string;
prevLabel?: string;
nextLabel?: string;
playLabel?: string;
pauseLabel?: string;
indicatorsLabel?: string;
}interface GrCarouselEmits {
(e: 'update:modelValue', index: number): void;
}Filedist/types/components/GrCarousel/GrCarouselSlide.vue.d.ts
interface GrCarouselSlideProps {
label?: string;
thumbnail?: string;
}Filedist/types/components/GrCarousel/grCarouselStyles.d.ts
type GrCarouselIndicators = typeof GR_CAROUSEL_INDICATORS[number];type GrCarouselActivationMode = typeof GR_CAROUSEL_ACTIVATION_MODES[number];Filedist/types/components/GrCarousel/safelist.d.ts
const grCarouselSafelist: string[];Filedist/types/components/GrCarousel/index.d.ts
type GrCarouselInstance = ComponentExposed<typeof GrCarouselComponent>;Accessibility
APG pattern
carousel
Keys
Home / End
Full contract of the package: docs/keyboard.md ↗
Usage from the library prose
The ring closes without a rewind
loop is on by default, and it used to simply reset the index: the strip
scrolled back through all the slides. On a ten-slide carousel with autoplay
every tenth step was a long backward run.
Now at the boundary of the ring the outermost slide temporarily moves over to the opposite end, the strip travels to it as to an ordinary neighbour, and only after the transition the position is reset without animation. There is no clone here and there cannot be one: the slides arrive through a slot, and a copy of someone else’s node would take its state and its refs along with it.
The model is updated at once, without waiting for the end of the transition:
modelValue is about which slide is shown, not about whether the strip has
arrived.
The axis of motion
<GrCarousel orientation="vertical" class="h-56">
<GrCarouselSlide label="November">…</GrCarouselSlide>
</GrCarousel>A vertical strip needs a definite viewport height. The step is computed from it, and without a height the column of slides grows with its content, and “by one slide” means the whole strip at once. If the height is zero, the component says so in a dev build instead of silently drawing a column.
The arrows move over to the axis of motion, the cross-axis scrolling of the page
stays with the page (touch-action), and the strip of indicators stays
horizontal: it is about choosing a slide, not about the direction of the strip.
Virtualisation
<GrCarousel virtual :virtual-overscan="1">
<GrCarouselSlide label="Slide">…</GrCarouselSlide>
</GrCarousel>What is virtualised is the inside of a slide, not the slide. The slides arrive through a slot, and the strip cannot refuse to render someone else’s node — but it can ask it not to draw its content. An empty wrapper holds its place in the row and weighs almost nothing, while the pictures and cards leave the DOM.
virtualOverscan is how many slides on either side to draw beyond the current
one. It is never less than one: the next slide must be ready before the
transition, otherwise the strip would travel onto emptiness and the content would
show up already in place. With loop the window is closed together with the
strip — the left-hand neighbours of the first slide are the last ones.
The slides arrive through a slot, not through an items prop
The markup of a slide is everyone’s own: for a gallery it is an <img>, for
onboarding a heading, a paragraph and a button, for a strip of reviews a card. An
array of data would force either a render function or a template prop for every
kind of slide, so a slide is a GrCarouselSlide with arbitrary content inside.
A consequence worth knowing about: the component learns the composition of the strip from the slides themselves, at mount time. On the server the strip of indicators comes out empty and is filled in on the next tick on the client — the first client render repeats the server one, and hydration stays intact.
It is a carousel, not a tablist
The root is role="group" (or region with landmark) with
aria-roledescription="carousel". There is deliberately no default name:
aria-roledescription does not count as a name, and a substituted “carousel”
would be read by the screen reader twice in a row. So ariaLabel or
ariaLabelledby are mandatory, and without them the component warns in dev.
The role of a slide depends on the presence of an indicator, and only on it:
with a strip a slide is a tabpanel tied to its tab, without a strip it is a
group named “N of M”. The look of the indicator has no effect on the role: dots
and thumbnails are one and the same tablist in two skins, otherwise a cosmetic
prop would rewrite the accessibility contract.
A slide that is not shown right now is marked inert. That is not polish: the
slides lie in one row and are clipped by the viewport, so without it Tab drives
off into an invisible slide, and the browser scrolls the container to it and
falls out of step with the strip.
The strip has no keys of its own
The strip is not focusable and is not a scroller — the shift is done by a
transform, not by scrolling. The ring of the arrow keys lives at the strip of
indicators: ←/→ walk the slides, Home/End go to the ends, the strip holds
a single Tab stop. With activationMode="manual" an arrow moves only the
focus, and the slide is confirmed by Enter or Space.
The ring of the strip is always closed, regardless of loop: loop governs the
arrows and the show, while any slide can be chosen directly in any case.
The show stops by itself
A cursor over the strip, a gesture in progress and a tab in the background put the countdown on pause: the remainder is kept, and on return the strip counts it down rather than starting the interval anew. Focus that has entered inside stops the show and does not resume it by itself — otherwise the slide would drive away from under the finger while the user reads a link inside it.
The pause button is drawn whenever autoplay is set, and there is nothing to
hide it with: APG forbids a moving carousel without a mechanism to stop it.
Under prefers-reduced-motion the show does not start, but the button stays and
offers to launch it — “reduce motion” does not mean “take the possibility away”.
The global clamp does not help here: it kills CSS, while the countdown is driven
by a timer, and the strip would keep travelling for the very person who asked it
not to.
A swipe does not cancel a click
A gesture begins only where there is no link, button or field under the pointer: the interactive stuff inside a slide stays interactive. Further, the gesture must exceed a threshold — the larger of 60 px and 15 % of the strip’s width — and run more horizontally than vertically, otherwise it is page scrolling, and the carousel does not intercept it. Vertical scrolling of the page stays with the browser: the strip suppresses only the horizontal.
An interrupted gesture does not turn a slide. The browser takes the pointer away from the page — a system gesture, a call, loss of the window — and the user has not completed anything at that point.
A single slide and an empty strip are states, not edge cases
With a single slide the arrows, dots and thumbnails are not drawn at all rather
than drawn disabled: a disabled control reports that there is somewhere to go.
An empty strip is a v-if on the consumer’s side: the consumer writes the
slides, and the component will not substitute a “nothing here” text for them.
The thumbnail is taken from three places
In descending order of strength: the slide’s #thumbnail slot, then its
thumbnail prop with a URL, then the slide’s number in a tile of the same size.
The third option keeps the rhythm of the strip when there is no picture at all;
if no slide has supplied a thumbnail with indicators="thumbnails", the
component warns in dev.
The component does not look for the first <img> inside a slide: walking the
markup does not work on the server, breaks on slides without pictures and hides
the contract from the reader.
Custom arrow icons and the tone of the indicators
The #prev and #next slots replace the content of the button, not the
button itself: the name, aria-disabled and the behaviour at the edge stay with
the component. Otherwise a custom arrow would bring along the duty to repeat the
whole contract — and it is most often not repeated.
The tone of the current indicator is taken from the package’s shared scale
(tone), not from a colour set of its own. A one-off repaint is still done
through --gr-carousel-dot-active: the prop sets the role, the hook sets the
value. neutral has no saturated colour, so its current dot (and the frame of
the current thumbnail) takes the colour of the main text, --gr-fg, like the
neutral counter of GrBadgeWrap. The surface grey will not do here: the
inactive dots are drawn with it.
The current dot carries two marks: a fill and an outline around it. A fill alone is not enough — at a diameter of eight pixels it distinguished the dot only by shade, and on a muted tone the difference almost vanished. The outline adds shape, that is, it works where colour is not told apart.
The strip of thumbnails moves itself, not the page
Scrolling to the current thumbnail is computed arithmetically and assigned to the
scrollLeft of the strip itself. scrollIntoView is no good here: it moves
all the ancestors, the document included — and a carousel that has gone
below the fold would drag the page back to itself on every step of autoplay.
Limits
- there is no virtualisation — all the slides lie in the DOM. A strip of hundreds of heavy slides requires a solution of its own on the consumer’s side;
- there is no endless strip by cloning —
looprewinds the index, so on the transition from the last slide to the first the strip travels back rather than continuing its motion; - there is no vertical orientation;
- the arrows lie above the slide — they are meant for a photo, where there is nothing to cover. A slide with text must keep its content away from them with margins at the edges (a 36 px button plus an 8 px gap on each side), otherwise the first letters of a line go under the button. The component cannot move the arrows outside the strip;
- the height is set by the tallest slide — the slides lie in one row. A constant height, if one is needed, is set by the content of the slide.
Props in this example