Skip to main content
Nexus Design System
GitHub

Carousel

Carousel shows a set of slides that readers move through one at a time. Use it for galleries and featured content. Install the embla-carousel-react peer dependency first.

1
2
3
4
5
'use client';

import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from '@/components/nxds/components/carousel/carousel';

const slideItems = [1, 2, 3, 4, 5].map((n) => (
  <CarouselItem key={n} aria-label={`Slide ${n} of 5`}>
    <div className="nx:flex nx:aspect-square nx:items-center nx:justify-center nx:rounded-md nx:border-default nx:border-border-default">
      <span className="nx:text-[2.25rem] nx:font-semibold">{n}</span>
    </div>
  </CarouselItem>
));

export default function CarouselDefault() {
  return (
    <div className="nx:mx-auto nx:w-full nx:max-w-xs">
      <Carousel className="nx:w-full" aria-label="Image carousel">
        <CarouselContent>{slideItems}</CarouselContent>
        <CarouselPrevious />
        <CarouselNext />
      </Carousel>
    </div>
  );
}

Installation

npx nxds-cli@latest add carousel

Run npx nxds-cli@latest init first if nxds isn’t set up in your app yet. See Install.

Usage

import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from '@/components/nxds/components/carousel/carousel';

Composition

Carousel
├── CarouselContent
│   └── CarouselItem
├── CarouselPrevious
└── CarouselNext

Examples

Vertical

Scroll slides vertically, with a fixed height on CarouselContent.

1
2
3
4
5
'use client';

import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from '@/components/nxds/components/carousel/carousel';

const slideItems = [1, 2, 3, 4, 5].map((n) => (
  <CarouselItem key={n} aria-label={`Slide ${n} of 5`}>
    <div className="nx:flex nx:aspect-square nx:items-center nx:justify-center nx:rounded-md nx:border-default nx:border-border-default">
      <span className="nx:text-[2.25rem] nx:font-semibold">{n}</span>
    </div>
  </CarouselItem>
));

export default function CarouselVertical() {
  return (
    <div className="nx:mx-auto nx:w-full nx:max-w-xs">
      <Carousel
        orientation="vertical"
        className="nx:w-full"
        aria-label="Vertical carousel"
      >
        <CarouselContent className="nx:h-[350px]">{slideItems}</CarouselContent>
        <CarouselPrevious />
        <CarouselNext />
      </Carousel>
    </div>
  );
}

Props

Carousel props
PropTypeDefaultDescription
optsPartial<OptionsType>No default documented

Embla options, such as loop, align or dragFree.

orientation"horizontal" | "vertical""horizontal"

Scroll axis.

pluginsCreatePluginType<LoosePluginType, {}>[]No default documented

Embla plugins, such as autoplay or wheel gestures.

setApi((api: EmblaCarouselType) => void)No default documented

Receives the Embla API after setup, for external control.

CarouselContent adds no props of its own; it accepts the props of the element or primitive it wraps.

CarouselItem adds no props of its own; it accepts the props of the element or primitive it wraps.

CarouselNext props
PropTypeDefaultDescription
asChildbooleanfalse

Renders the child, such as an <a>, with button styles. The loading spinner and icon slots then don't render.

endIconReactNodeNo default documented

Decorative icon after the label. Use endIcon or startIcon, not both.

loadingbooleanfalse

Disables the button and shows a spinner in place of its content.

size"default" | "sm" | "lg" | "icon-sm" | "icon" | "icon-lg" | null"icon"No description
startIconReactNodeNo default documented

Decorative icon before the label. Use startIcon or endIcon, not both.

variant"link" | "default" | "error" | "destructive" | "outline" | "dashed" | "secondary" | "ghost" | null"outline"No description
CarouselPrevious props
PropTypeDefaultDescription
asChildbooleanfalse

Renders the child, such as an <a>, with button styles. The loading spinner and icon slots then don't render.

endIconReactNodeNo default documented

Decorative icon after the label. Use endIcon or startIcon, not both.

loadingbooleanfalse

Disables the button and shows a spinner in place of its content.

size"default" | "sm" | "lg" | "icon-sm" | "icon" | "icon-lg" | null"icon"No description
startIconReactNodeNo default documented

Decorative icon before the label. Use startIcon or endIcon, not both.

variant"link" | "default" | "error" | "destructive" | "outline" | "dashed" | "secondary" | "ghost" | null"outline"No description

Carousel shows a set of slides that readers move through one at a time. Use it for galleries and featured content. Install the embla-carousel-vue peer dependency first.

<script setup lang="ts">
import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from '@/components/carousel';
</script>

<template>
  <Carousel class="nx:w-full" aria-label="Image carousel">
    <CarouselContent>
      <CarouselItem
        v-for="n in 5"
        :key="n"
        :aria-label="'Slide ' + n + ' of 5'"
      >
        <div
          class="nx:flex nx:aspect-square nx:items-center nx:justify-center nx:rounded-md nx:border-default nx:border-border-default"
        >
          <span class="nx:text-[2.25rem] nx:font-semibold">{{ n }}</span>
        </div>
      </CarouselItem>
    </CarouselContent>
    <CarouselPrevious />
    <CarouselNext />
  </Carousel>
</template>

Installation

  1. Install these components first:

    Button, ButtonGroup, Spinner.

  2. Install the following dependencies:

    npm install embla-carousel-vue@^8.6.0
    
  3. Copy and paste the following code into your project.

    components/carousel/Carousel.vue
    <script setup lang="ts">
    import emblaCarouselVue from 'embla-carousel-vue';
    import {
      computed,
      type HTMLAttributes,
      type MaybeRef,
      provide,
      ref,
      toRef,
      watch,
    } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    import { type CarouselApi, CarouselKey } from '.';
    
    // Embla's option and plugin types are unwrapped inline, so the props table
    // prints Embla's own type names.
    export interface CarouselProps {
      /** Embla options, such as `loop`, `align` or `dragFree`. */
      opts?: Parameters<typeof emblaCarouselVue>[0] extends
        | MaybeRef<infer Options>
        | undefined
        ? Options
        : never;
      /** Embla plugins, such as autoplay or wheel gestures. */
      plugins?: Parameters<typeof emblaCarouselVue>[1] extends
        | MaybeRef<infer Plugins>
        | undefined
        ? Plugins
        : never;
      /**
       * Scroll axis.
       */
      orientation?: 'horizontal' | 'vertical';
      /** Receives the Embla API after setup, for external control. */
      setApi?: (api: CarouselApi) => void;
      class?: HTMLAttributes['class'];
    }
    
    const props = withDefaults(defineProps<CarouselProps>(), {
      orientation: 'horizontal',
    });
    
    const [carouselRef, api] = emblaCarouselVue(
      computed(() => ({
        ...props.opts,
        axis: props.orientation === 'horizontal' ? 'x' : 'y',
      })),
      computed(() => props.plugins ?? [])
    );
    
    const canScrollPrev = ref(false);
    const canScrollNext = ref(false);
    
    function readScrollState(carousel: CarouselApi) {
      canScrollPrev.value = carousel.canScrollPrev();
      canScrollNext.value = carousel.canScrollNext();
    }
    
    // Embla is an external system: read its scroll position now, follow it on
    // select and reInit, and hand the API to the consumer once it exists.
    watch(api, (carousel, _, onCleanup) => {
      if (!carousel) return;
      readScrollState(carousel);
      carousel.on('select', readScrollState).on('reInit', readScrollState);
      onCleanup(() => {
        carousel.off('select', readScrollState).off('reInit', readScrollState);
      });
      props.setApi?.(carousel);
    });
    
    function scrollPrev() {
      api.value?.scrollPrev();
    }
    
    function scrollNext() {
      api.value?.scrollNext();
    }
    
    function handleKeyDown(event: KeyboardEvent) {
      const isPrev =
        event.key ===
        (props.orientation === 'horizontal' ? 'ArrowLeft' : 'ArrowUp');
      const isNext =
        event.key ===
        (props.orientation === 'horizontal' ? 'ArrowRight' : 'ArrowDown');
      if (!isPrev && !isNext) return;
      // Don't hijack arrow keys from a focused control that uses them itself.
      if (
        (event.target as HTMLElement).closest(
          'input, textarea, select, [contenteditable="true"]'
        )
      ) {
        return;
      }
    
      if (isPrev && !canScrollPrev.value) return;
      if (isNext && !canScrollNext.value) return;
    
      event.preventDefault();
      if (isPrev) scrollPrev();
      else scrollNext();
    }
    
    provide(CarouselKey, {
      carouselRef,
      orientation: toRef(props, 'orientation'),
      scrollPrev,
      scrollNext,
      canScrollPrev,
      canScrollNext,
    });
    </script>
    
    <template>
      <div
        :class="cn('nx:relative', props.class)"
        role="region"
        aria-roledescription="carousel"
        data-slot="carousel"
        :data-orientation="orientation"
        @keydown.capture="handleKeyDown"
      >
        <slot />
      </div>
    </template>
    
    components/carousel/CarouselContent.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    import { injectCarousel } from '.';
    
    defineOptions({ inheritAttrs: false });
    
    export interface CarouselContentProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<CarouselContentProps>();
    
    const { carouselRef, orientation } = injectCarousel();
    </script>
    
    <template>
      <div
        ref="carouselRef"
        class="nx:overflow-hidden"
        data-slot="carousel-content"
      >
        <div
          v-bind="$attrs"
          :class="
            cn(
              'nx:flex',
              orientation === 'horizontal' ? 'nx:-ml-4' : 'nx:-mt-4 nx:flex-col',
              props.class
            )
          "
        >
          <slot />
        </div>
      </div>
    </template>
    
    components/carousel/CarouselItem.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    import { injectCarousel } from '.';
    
    export interface CarouselItemProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<CarouselItemProps>();
    
    const { orientation } = injectCarousel();
    </script>
    
    <template>
      <div
        role="group"
        aria-roledescription="slide"
        data-slot="carousel-item"
        :class="
          cn(
            'nx:min-w-0 nx:shrink-0 nx:grow-0 nx:basis-full',
            orientation === 'horizontal' ? 'nx:pl-4' : 'nx:pt-4',
            props.class
          )
        "
      >
        <slot />
      </div>
    </template>
    
    components/carousel/CarouselNext.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import type { VNode } from 'vue';
    
    import { IconArrowRight } from '../../lib/icons';
    import { cn } from '../../lib/utils';
    import { Button, type ButtonProps, type ButtonVariants } from '../button';
    
    import { carouselControlClasses, injectCarousel } from '.';
    
    export interface CarouselNextProps extends Omit<
      ButtonProps,
      'variant' | 'size'
    > {
      variant?: ButtonVariants['variant'];
      size?: ButtonVariants['size'];
    }
    
    const props = withDefaults(defineProps<CarouselNextProps>(), {
      as: 'button',
      variant: 'outline',
      size: 'icon',
      loading: false,
      disabled: undefined,
      type: 'button',
    });
    
    const slots = defineSlots<{
      'start-icon'?: () => VNode[];
      'end-icon'?: () => VNode[];
    }>();
    
    const delegated = reactiveOmit(props, 'class', 'disabled');
    
    const { orientation, scrollNext, canScrollNext } = injectCarousel();
    </script>
    
    <template>
      <Button
        v-bind="delegated"
        data-slot="carousel-next"
        :disabled="disabled ?? !canScrollNext"
        :class="
          cn(
            carouselControlClasses,
            orientation === 'horizontal'
              ? 'nx:top-1/2 nx:-right-12 nx:-translate-y-1/2'
              : 'nx:-bottom-12 nx:left-1/2 nx:-translate-x-1/2 nx:rotate-90',
            props.class
          )
        "
        @click="scrollNext"
      >
        <template v-if="slots['start-icon']" #start-icon>
          <slot name="start-icon" />
        </template>
        <IconArrowRight />
        <span class="nx:sr-only">Next slide</span>
        <template v-if="slots['end-icon']" #end-icon>
          <slot name="end-icon" />
        </template>
      </Button>
    </template>
    
    components/carousel/CarouselPrevious.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import type { VNode } from 'vue';
    
    import { IconArrowLeft } from '../../lib/icons';
    import { cn } from '../../lib/utils';
    import { Button, type ButtonProps, type ButtonVariants } from '../button';
    
    import { carouselControlClasses, injectCarousel } from '.';
    
    export interface CarouselPreviousProps extends Omit<
      ButtonProps,
      'variant' | 'size'
    > {
      variant?: ButtonVariants['variant'];
      size?: ButtonVariants['size'];
    }
    
    const props = withDefaults(defineProps<CarouselPreviousProps>(), {
      as: 'button',
      variant: 'outline',
      size: 'icon',
      loading: false,
      disabled: undefined,
      type: 'button',
    });
    
    const slots = defineSlots<{
      'start-icon'?: () => VNode[];
      'end-icon'?: () => VNode[];
    }>();
    
    const delegated = reactiveOmit(props, 'class', 'disabled');
    
    const { orientation, scrollPrev, canScrollPrev } = injectCarousel();
    </script>
    
    <template>
      <Button
        v-bind="delegated"
        data-slot="carousel-previous"
        :disabled="disabled ?? !canScrollPrev"
        :class="
          cn(
            carouselControlClasses,
            orientation === 'horizontal'
              ? 'nx:top-1/2 nx:-left-12 nx:-translate-y-1/2'
              : 'nx:-top-12 nx:left-1/2 nx:-translate-x-1/2 nx:rotate-90',
            props.class
          )
        "
        @click="scrollPrev"
      >
        <template v-if="slots['start-icon']" #start-icon>
          <slot name="start-icon" />
        </template>
        <IconArrowLeft />
        <span class="nx:sr-only">Previous slide</span>
        <template v-if="slots['end-icon']" #end-icon>
          <slot name="end-icon" />
        </template>
      </Button>
    </template>
    
    components/carousel/index.ts
    import type { EmblaCarouselVueType } from 'embla-carousel-vue';
    import { inject, type InjectionKey, type Ref, type UnwrapRef } from 'vue';
    
    export { default as Carousel, type CarouselProps } from './Carousel.vue';
    export {
      default as CarouselContent,
      type CarouselContentProps,
    } from './CarouselContent.vue';
    export {
      default as CarouselItem,
      type CarouselItemProps,
    } from './CarouselItem.vue';
    export {
      default as CarouselNext,
      type CarouselNextProps,
    } from './CarouselNext.vue';
    export {
      default as CarouselPrevious,
      type CarouselPreviousProps,
    } from './CarouselPrevious.vue';
    
    export type CarouselApi = NonNullable<UnwrapRef<EmblaCarouselVueType[1]>>;
    
    export const CarouselKey: InjectionKey<{
      carouselRef: EmblaCarouselVueType[0];
      orientation: Ref<'horizontal' | 'vertical'>;
      scrollPrev: () => void;
      scrollNext: () => void;
      canScrollPrev: Ref<boolean>;
      canScrollNext: Ref<boolean>;
    }> = Symbol('carousel');
    
    /** The state the Carousel provides to its parts. */
    export function injectCarousel() {
      const carousel = inject(CarouselKey);
      if (!carousel) {
        throw new Error('Carousel parts must be used within a <Carousel />');
      }
      return carousel;
    }
    
    export const carouselControlClasses =
      'nx:absolute nx:rounded-full nx:after:absolute nx:after:-inset-2';
    
  4. Keep each file at the path shown above it.

    Paths are relative to packages/vue/src/. Keep the same folders under your @/ root, and the imports between files work without edits.

Usage

import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from '@/components/carousel';

Examples

Vertical

Scroll slides vertically, with a fixed height on CarouselContent.

<script setup lang="ts">
import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from '@/components/carousel';
</script>

<template>
  <Carousel
    orientation="vertical"
    class="nx:w-full"
    aria-label="Vertical carousel"
  >
    <CarouselContent class="nx:h-[350px]">
      <CarouselItem
        v-for="n in 5"
        :key="n"
        :aria-label="'Slide ' + n + ' of 5'"
      >
        <div
          class="nx:flex nx:aspect-square nx:items-center nx:justify-center nx:rounded-md nx:border-default nx:border-border-default"
        >
          <span class="nx:text-[2.25rem] nx:font-semibold">{{ n }}</span>
        </div>
      </CarouselItem>
    </CarouselContent>
    <CarouselPrevious />
    <CarouselNext />
  </Carousel>
</template>

Props

Carousel props
PropTypeDefaultDescription
optsPartial<OptionsType>No default documented

Embla options, such as loop, align or dragFree.

orientation"vertical" | "horizontal""horizontal"

Scroll axis.

pluginsCreatePluginType<LoosePluginType, {}>[]No default documented

Embla plugins, such as autoplay or wheel gestures.

setApi((api: EmblaCarouselType) => void)No default documented

Receives the Embla API after setup, for external control.

CarouselContent adds no props of its own; it accepts the props of the element or primitive it wraps.

CarouselItem adds no props of its own; it accepts the props of the element or primitive it wraps.

CarouselNext props
PropTypeDefaultDescription
disabledbooleanNo default documentedNo description
loadingbooleanfalse

Disables the button and shows a spinner in place of its content.

size"default" | "sm" | "lg" | "icon-sm" | "icon" | "icon-lg" | null"icon"No description
type"button" | "submit" | "reset""button"No description
variant"default" | "destructive" | "error" | "outline" | "dashed" | "secondary" | "ghost" | "link" | null"outline"No description
CarouselPrevious props
PropTypeDefaultDescription
disabledbooleanNo default documentedNo description
loadingbooleanfalse

Disables the button and shows a spinner in place of its content.

size"default" | "sm" | "lg" | "icon-sm" | "icon" | "icon-lg" | null"icon"No description
type"button" | "submit" | "reset""button"No description
variant"default" | "destructive" | "error" | "outline" | "dashed" | "secondary" | "ghost" | "link" | null"outline"No description