├── .nvmrc ├── readme.md ├── site ├── .eslintignore ├── .eslintrc.json ├── app │ ├── favicon.ico │ ├── layout.tsx │ ├── styles.css │ ├── page.tsx │ ├── scrollend-polyfill.ts │ ├── header.tsx │ ├── prop-data.ts │ ├── HeroCarousel.tsx │ └── intro.mdx ├── assets │ └── images │ │ ├── 1.webp │ │ ├── 2.webp │ │ ├── 3.webp │ │ ├── 4.webp │ │ ├── 5.webp │ │ ├── 6.webp │ │ ├── 7.webp │ │ ├── 8.webp │ │ └── 9.webp ├── postcss.config.cjs ├── global.d.ts ├── next-env.d.ts ├── mdx-components.tsx ├── examples │ ├── basic │ │ ├── index.tsx │ │ ├── styles.module.css │ │ └── Basic.tsx │ ├── loop │ │ ├── index.tsx │ │ ├── styles.module.css │ │ └── Looping.tsx │ ├── autoplay │ │ ├── index.tsx │ │ ├── Autoplay.tsx │ │ └── styles.module.css │ ├── translations │ │ ├── index.tsx │ │ ├── Translated.tsx │ │ └── styles.module.css │ ├── orientation │ │ ├── index.tsx │ │ ├── Orientation.tsx │ │ └── styles.module.css │ ├── scroll-hints │ │ ├── index.tsx │ │ ├── ScrollHints.tsx │ │ └── styles.module.css │ ├── mouse-dragging │ │ ├── index.tsx │ │ ├── MouseDragging.tsx │ │ └── styles.module.css │ ├── multiple-items │ │ ├── index.tsx │ │ ├── MultipleItems.tsx │ │ └── styles.module.css │ └── styles.module.css ├── tsconfig.json ├── components │ ├── CodeDemo │ │ ├── highlightCode.ts │ │ ├── index.tsx │ │ └── CodeArea.tsx │ ├── Image.tsx │ ├── Sidebar.tsx │ └── PropTable.tsx ├── next.config.mjs ├── README.md ├── hooks │ └── useMatchMedia.ts ├── package.json └── panda.config.ts ├── .prettierignore ├── react-aria-carousel ├── .eslintignore ├── .storybook │ ├── styles.css │ ├── preview.ts │ ├── main.ts │ └── test-runner.ts ├── tsconfig.build.json ├── postcss.config.cjs ├── jsr.jsonc ├── tsup.config.ts ├── setupTests.ts ├── src │ ├── utils │ │ ├── useCallbackRef.ts │ │ ├── index.ts │ │ ├── usePrefersReducedMotion.ts │ │ ├── useAriaBusyScroll.ts │ │ ├── useMergedRef.ts │ │ ├── useAutoplay.ts │ │ ├── mergeProps.ts │ │ └── useMouseDrag.ts │ ├── context.ts │ ├── CarouselItem.tsx │ ├── index.ts │ ├── CarouselButton.tsx │ ├── CarouselAutoplayControl.tsx │ ├── useCarouselTab.ts │ ├── useCarouselItem.ts │ ├── Carousel.tsx │ ├── CarouselTabs.tsx │ ├── CarouselScroller.tsx │ ├── useCarousel.ts │ └── useCarouselState.ts ├── vite.config.ts ├── tsconfig.json ├── .eslintrc.cjs ├── CHANGELOG.md ├── stories │ ├── styles.css │ ├── ComposedCarousel.tsx │ └── Test.stories.tsx ├── panda.config.ts ├── tests │ ├── ComposedCarousel.tsx │ ├── index.test.tsx │ └── Component.test.tsx ├── package.json └── readme.md ├── bun.lockb ├── prettier.config.cjs ├── .gitignore ├── .github └── workflows │ ├── validate.yml │ └── chromatic.yml ├── turbo.json ├── tsconfig.json ├── package.json └── LICENSE /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | react-aria-carousel/readme.md -------------------------------------------------------------------------------- /site/.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | styled-system 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | styled-system 4 | .next 5 | -------------------------------------------------------------------------------- /site/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /react-aria-carousel/.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | styled-system 4 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roginfarrer/react-aria-carousel/HEAD/bun.lockb -------------------------------------------------------------------------------- /react-aria-carousel/.storybook/styles.css: -------------------------------------------------------------------------------- 1 | @layer reset, base, tokens, recipes, utilities; 2 | -------------------------------------------------------------------------------- /site/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roginfarrer/react-aria-carousel/HEAD/site/app/favicon.ico -------------------------------------------------------------------------------- /react-aria-carousel/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src"] 4 | } 5 | -------------------------------------------------------------------------------- /site/assets/images/1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roginfarrer/react-aria-carousel/HEAD/site/assets/images/1.webp -------------------------------------------------------------------------------- /site/assets/images/2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roginfarrer/react-aria-carousel/HEAD/site/assets/images/2.webp -------------------------------------------------------------------------------- /site/assets/images/3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roginfarrer/react-aria-carousel/HEAD/site/assets/images/3.webp -------------------------------------------------------------------------------- /site/assets/images/4.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roginfarrer/react-aria-carousel/HEAD/site/assets/images/4.webp -------------------------------------------------------------------------------- /site/assets/images/5.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roginfarrer/react-aria-carousel/HEAD/site/assets/images/5.webp -------------------------------------------------------------------------------- /site/assets/images/6.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roginfarrer/react-aria-carousel/HEAD/site/assets/images/6.webp -------------------------------------------------------------------------------- /site/assets/images/7.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roginfarrer/react-aria-carousel/HEAD/site/assets/images/7.webp -------------------------------------------------------------------------------- /site/assets/images/8.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roginfarrer/react-aria-carousel/HEAD/site/assets/images/8.webp -------------------------------------------------------------------------------- /site/assets/images/9.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roginfarrer/react-aria-carousel/HEAD/site/assets/images/9.webp -------------------------------------------------------------------------------- /site/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "@pandacss/dev/postcss": {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /react-aria-carousel/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [require("@pandacss/dev/postcss")()], 3 | }; 4 | -------------------------------------------------------------------------------- /site/global.d.ts: -------------------------------------------------------------------------------- 1 | import "react"; 2 | 3 | declare module "react" { 4 | interface CSSProperties { 5 | [key: `--${string}`]: string | number; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /react-aria-carousel/jsr.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rogin/react-aria-carousel", 3 | "version": "0.0.1", 4 | "exports": "./src/index.ts", 5 | "publish": { 6 | "include": ["src", "package.json"], 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /site/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: ["@ianvs/prettier-plugin-sort-imports"], 3 | importOrder: [ 4 | "^react$", 5 | "", 6 | "", 7 | "^(?!.*[.]css$)[./].*$", 8 | ".css$", 9 | ], 10 | }; 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .DS_Store 4 | 5 | ## Panda 6 | styled-system 7 | styled-system-studio 8 | 9 | ## Storybook 10 | *storybook.log 11 | storybook-static 12 | 13 | ## Next 14 | .next 15 | .vercel 16 | 17 | .parcel-cache 18 | 19 | tsconfig.tsbuildinfo 20 | .turbo 21 | 22 | -------------------------------------------------------------------------------- /react-aria-carousel/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | entry: ["./src/index.ts"], 5 | dts: true, 6 | clean: true, 7 | sourcemap: true, 8 | format: ["esm", "cjs"], 9 | outDir: "dist", 10 | external: ["react", "react-dom"], 11 | }); 12 | -------------------------------------------------------------------------------- /react-aria-carousel/setupTests.ts: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom/vitest"; 2 | 3 | import { vi } from "vitest"; 4 | 5 | const IntersectionObserverMock = vi.fn(() => ({ 6 | disconnect: vi.fn(), 7 | observe: vi.fn(), 8 | takeRecords: vi.fn(), 9 | unobserve: vi.fn(), 10 | })); 11 | 12 | vi.stubGlobal("IntersectionObserver", IntersectionObserverMock); 13 | -------------------------------------------------------------------------------- /site/mdx-components.tsx: -------------------------------------------------------------------------------- 1 | import type { MDXComponents } from "mdx/types"; 2 | 3 | import { css } from "./styled-system/css"; 4 | 5 | export function useMDXComponents(components: MDXComponents): MDXComponents { 6 | return { 7 | ...components, 8 | table: (props) => ( 9 |
10 | 11 | 12 | ), 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /react-aria-carousel/src/utils/useCallbackRef.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useRef } from "react"; 2 | 3 | export function useCallbackRef any>( 4 | callback: T | undefined, 5 | ): T { 6 | const callbackRef = useRef(callback); 7 | 8 | useEffect(() => { 9 | callbackRef.current = callback; 10 | }); 11 | 12 | return useMemo(() => ((...args) => callbackRef.current?.(...args)) as T, []); 13 | } 14 | -------------------------------------------------------------------------------- /react-aria-carousel/vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import react from "@vitejs/plugin-react"; 4 | import { defineConfig } from "vite"; 5 | import tsconfigPaths from "vite-tsconfig-paths"; 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig({ 9 | plugins: [react(), tsconfigPaths()], 10 | test: { 11 | environment: "happy-dom", 12 | globals: true, 13 | setupFiles: ["./setupTests.ts"], 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: # Rebuild any PRs and main branch changes 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | name: Test 12 | runs-on: ubuntu-latest 13 | env: 14 | CI: true 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: oven-sh/setup-bun@v1 18 | 19 | - run: bun install 20 | - run: bun run build 21 | - run: bun lint 22 | -------------------------------------------------------------------------------- /react-aria-carousel/.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import type { Preview } from "@storybook/react"; 2 | 3 | import "./styles.css"; 4 | 5 | const preview: Preview = { 6 | parameters: { 7 | chromatic: { delay: 300 }, 8 | docs: { 9 | toc: { 10 | headingSelector: "h1,h2,h3", 11 | }, 12 | }, 13 | controls: { 14 | matchers: { 15 | color: /(background|color)$/i, 16 | date: /Date$/i, 17 | }, 18 | }, 19 | }, 20 | }; 21 | 22 | export default preview; 23 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "tasks": { 4 | "build": { 5 | "outputs": [".next/**", "!.next/cache/**"], 6 | "dependsOn": ["^build"] 7 | }, 8 | "test": { 9 | "dependsOn": ["^build"], 10 | "inputs": [ 11 | "src/**/*.tsx", 12 | "src/**/*.ts", 13 | "tests/**/*.ts", 14 | "tests/**/*.tsx" 15 | ] 16 | }, 17 | "lint": {}, 18 | "dev": { 19 | "cache": false, 20 | "persistent": true 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "target": "ESNext", 5 | "module": "ESNext", 6 | "moduleResolution": "Bundler", 7 | "isolatedModules": true, 8 | "lib": ["dom", "ESNext"], 9 | "jsx": "react-jsx", 10 | "strict": true, 11 | "allowSyntheticDefaultImports": true, 12 | "composite": true, 13 | "resolveJsonModule": true, 14 | "allowJs": true, 15 | "esModuleInterop": true 16 | }, 17 | "references": [{ "path": "./react-aria-carousel" }, { "path": "./site" }] 18 | } 19 | -------------------------------------------------------------------------------- /react-aria-carousel/.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from "@storybook/react-vite"; 2 | 3 | const config: StorybookConfig = { 4 | stories: ["../@(src|stories)/**/*.stories.@(js|jsx|mjs|ts|tsx)"], 5 | addons: [ 6 | "@storybook/addon-links", 7 | "@storybook/addon-essentials", 8 | "@chromatic-com/storybook", 9 | "@storybook/addon-interactions", 10 | "@storybook/addon-a11y", 11 | ], 12 | framework: { 13 | name: "@storybook/react-vite", 14 | options: {}, 15 | }, 16 | }; 17 | export default config; 18 | -------------------------------------------------------------------------------- /site/examples/basic/index.tsx: -------------------------------------------------------------------------------- 1 | import { CodeDemo } from "@/components/CodeDemo"; 2 | 3 | import { Basic } from "./Basic"; 4 | import demoRaw from "./Basic?raw"; 5 | import stylesRaw from "!!raw-loader!./styles.module.css"; 6 | 7 | export default function Demo() { 8 | return ( 9 | 19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /site/examples/loop/index.tsx: -------------------------------------------------------------------------------- 1 | import { CodeDemo } from "@/components/CodeDemo"; 2 | 3 | import { Looping } from "./Looping"; 4 | import demoRaw from "./Looping?raw"; 5 | import stylesRaw from "!!raw-loader!./styles.module.css"; 6 | 7 | export default function Demo() { 8 | return ( 9 | 19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /site/examples/autoplay/index.tsx: -------------------------------------------------------------------------------- 1 | import { CodeDemo } from "@/components/CodeDemo"; 2 | 3 | import { Autoplay } from "./Autoplay"; 4 | import demoRaw from "./Autoplay?raw"; 5 | import stylesRaw from "!!raw-loader!./styles.module.css"; 6 | 7 | export default function Demo() { 8 | return ( 9 | 19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /site/examples/translations/index.tsx: -------------------------------------------------------------------------------- 1 | import { CodeDemo } from "@/components/CodeDemo"; 2 | 3 | import { Translated } from "./Translated"; 4 | import demoRaw from "./Translated?raw"; 5 | import stylesRaw from "!!raw-loader!./styles.module.css"; 6 | 7 | export default function Demo() { 8 | return ( 9 | 19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /site/examples/orientation/index.tsx: -------------------------------------------------------------------------------- 1 | import { CodeDemo } from "@/components/CodeDemo"; 2 | 3 | import { Orientation } from "./Orientation"; 4 | import demoRaw from "./Orientation?raw"; 5 | import stylesRaw from "!!raw-loader!./styles.module.css"; 6 | 7 | export default function Demo() { 8 | return ( 9 | 19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /site/examples/scroll-hints/index.tsx: -------------------------------------------------------------------------------- 1 | import { CodeDemo } from "@/components/CodeDemo"; 2 | 3 | import { ScrollHints } from "./ScrollHints"; 4 | import demoRaw from "./ScrollHints?raw"; 5 | import stylesRaw from "!!raw-loader!./styles.module.css"; 6 | 7 | export default function Demo() { 8 | return ( 9 | 19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /site/examples/mouse-dragging/index.tsx: -------------------------------------------------------------------------------- 1 | import { CodeDemo } from "@/components/CodeDemo"; 2 | 3 | import { MouseDragging } from "./MouseDragging"; 4 | import demoRaw from "./MouseDragging?raw"; 5 | import stylesRaw from "!!raw-loader!./styles.module.css"; 6 | 7 | export default function Demo() { 8 | return ( 9 | 19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /site/examples/multiple-items/index.tsx: -------------------------------------------------------------------------------- 1 | import { CodeDemo } from "@/components/CodeDemo"; 2 | 3 | import { MultipleItems } from "./MultipleItems"; 4 | import demoRaw from "./MultipleItems?raw"; 5 | import stylesRaw from "!!raw-loader!./styles.module.css"; 6 | 7 | export default function Demo() { 8 | return ( 9 | 19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /react-aria-carousel/.storybook/test-runner.ts: -------------------------------------------------------------------------------- 1 | import type { TestRunnerConfig } from "@storybook/test-runner"; 2 | import { checkA11y, injectAxe } from "axe-playwright"; 3 | 4 | /* 5 | * See https://storybook.js.org/docs/writing-tests/test-runner#test-hook-api 6 | * to learn more about the test-runner hooks API. 7 | */ 8 | const config: TestRunnerConfig = { 9 | async preVisit(page) { 10 | await injectAxe(page); 11 | }, 12 | async postVisit(page) { 13 | await checkA11y(page, "#storybook-root", { 14 | detailedReport: true, 15 | detailedReportOptions: { 16 | html: true, 17 | }, 18 | }); 19 | }, 20 | }; 21 | 22 | export default config; 23 | -------------------------------------------------------------------------------- /.github/workflows/chromatic.yml: -------------------------------------------------------------------------------- 1 | name: "Storybook Tests" 2 | 3 | on: # Rebuild any PRs and main branch changes 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | timeout-minutes: 10 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | - uses: oven-sh/setup-bun@v1 18 | 19 | - name: Install dependencies 20 | run: bun install 21 | - name: Run Chromatic 22 | uses: chromaui/action@latest 23 | with: 24 | projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} 25 | workingDir: react-aria-carousel 26 | -------------------------------------------------------------------------------- /react-aria-carousel/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "tests", "stories", "@testing-library/jest-dom/vitest"], 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "declaration": true, 6 | "noEmitOnError": true, 7 | "outDir": "./dist", 8 | "skipLibCheck": true, 9 | "target": "ESNext", 10 | "module": "ESNext", 11 | "moduleResolution": "Bundler", 12 | "isolatedModules": true, 13 | "lib": ["dom", "ESNext"], 14 | "jsx": "react-jsx", 15 | "strict": true, 16 | "allowSyntheticDefaultImports": true, 17 | "resolveJsonModule": true, 18 | "allowJs": true, 19 | "esModuleInterop": true, 20 | "types": ["vitest/globals", "@testing-library/jest-dom"] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /react-aria-carousel/src/context.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from "react"; 2 | 3 | import { CarouselAria, CarouselOptions } from "./useCarousel"; 4 | 5 | interface ContextType { 6 | carouselState: CarouselAria; 7 | carouselProps: CarouselOptions; 8 | assignRef: (instance: HTMLElement | null) => void; 9 | } 10 | 11 | // @ts-expect-error purposefully undefined 12 | export const Context = createContext(undefined); 13 | 14 | export const useCarouselContext = () => { 15 | const context = useContext(Context); 16 | if (!context) { 17 | throw new Error("react-aria-carousel: No Carousel found in the React tree"); 18 | } 19 | return context; 20 | }; 21 | 22 | export const IndexContext = createContext(-1); 23 | -------------------------------------------------------------------------------- /react-aria-carousel/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { browser: true, es2020: true }, 3 | extends: [ 4 | "eslint:recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "plugin:react-hooks/recommended", 7 | "plugin:storybook/recommended", 8 | ], 9 | ignorePatterns: ["dist", ".eslintrc.cjs", "example", "storybook-static"], 10 | parser: "@typescript-eslint/parser", 11 | plugins: ["react-refresh"], 12 | rules: { 13 | "no-console": "error", 14 | "@typescript-eslint/ban-ts-comment": "off", 15 | "@typescript-eslint/no-explicit-any": "off", 16 | "prefer-const": "off", 17 | "react-refresh/only-export-components": [ 18 | "warn", 19 | { allowConstantExport: true }, 20 | ], 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /react-aria-carousel/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { ComponentPropsWithoutRef, ElementType } from "react"; 2 | 3 | export * from "./useAriaBusyScroll"; 4 | export * from "./useMouseDrag"; 5 | export * from "./useCallbackRef"; 6 | export * from "./usePrefersReducedMotion"; 7 | export * from "./useMergedRef"; 8 | export * from "./mergeProps"; 9 | 10 | export type Attributes = ComponentPropsWithoutRef & 11 | Partial> & { 12 | inert?: string; 13 | }; 14 | 15 | export function noop() {} 16 | 17 | export function clamp(min: number, value: number, max: number) { 18 | if (value < min) { 19 | return min; 20 | } 21 | if (value > max) { 22 | return max; 23 | } 24 | return value; 25 | } 26 | -------------------------------------------------------------------------------- /site/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | 3 | import "./styles.css"; 4 | import "@fontsource/ibm-plex-sans"; 5 | import "@fontsource/ibm-plex-mono"; 6 | 7 | import { css } from "@/styled-system/css"; 8 | 9 | export const metadata: Metadata = { 10 | title: "React Aria Carousel", 11 | description: "The carousel for the modern age", 12 | }; 13 | 14 | export default function RootLayout({ 15 | children, 16 | }: Readonly<{ 17 | children: React.ReactNode; 18 | }>) { 19 | return ( 20 | 21 | 27 | {children} 28 | 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /react-aria-carousel/src/utils/usePrefersReducedMotion.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | const QUERY = "(prefers-reduced-motion: no-preference)"; 4 | 5 | export function usePrefersReducedMotion() { 6 | const [prefersReducedMotion, setPrefersReducedMotion] = useState(false); 7 | 8 | useEffect(() => { 9 | const mediaQueryList = window.matchMedia(QUERY); 10 | setPrefersReducedMotion(!window.matchMedia(QUERY).matches); 11 | const listener = (event: MediaQueryListEvent) => { 12 | setPrefersReducedMotion(!event.matches); 13 | }; 14 | mediaQueryList.addEventListener("change", listener); 15 | return () => { 16 | mediaQueryList.removeEventListener("change", listener); 17 | }; 18 | }, []); 19 | 20 | return prefersReducedMotion; 21 | } 22 | -------------------------------------------------------------------------------- /site/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { "name": "next" }, 17 | { "name": "typescript-plugin-css-modules" } 18 | ], 19 | "baseUrl": ".", 20 | "paths": { 21 | "@/*": ["./*"], 22 | "styled-system/*": ["./styled-system/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "workspace", 3 | "private": true, 4 | "version": "0.0.1", 5 | "type": "module", 6 | "scripts": { 7 | "build": "turbo build", 8 | "oldbuild": "cd react-aria-carousel && bun run build && cd ../site && bun panda && bun run build", 9 | "prepare": "bun --cwd react-aria-carousel panda codegen && bun --cwd site panda codegen", 10 | "lint": "turbo lint", 11 | "dev": "turbo dev", 12 | "test": "turbo test" 13 | }, 14 | "devDependencies": { 15 | "@ianvs/prettier-plugin-sort-imports": "^4.2.1", 16 | "prettier": "^3.2.5", 17 | "turbo": "^2.0.7" 18 | }, 19 | "workspaces": [ 20 | "react-aria-carousel", 21 | "site" 22 | ], 23 | "trustedDependencies": [ 24 | "@pandadev/css", 25 | "example", 26 | "react-aria-carousel" 27 | ], 28 | "packageManager": "bun@1.1.8" 29 | } 30 | -------------------------------------------------------------------------------- /react-aria-carousel/src/utils/useAriaBusyScroll.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | export function useAriaBusyScroll(host?: HTMLElement | null) { 4 | useEffect(() => { 5 | if (!host) return; 6 | function onscroll() { 7 | if (!host) return; 8 | host.setAttribute("aria-busy", "true"); 9 | host.addEventListener("scrollend", onscrollend, { once: true }); 10 | } 11 | function onscrollend() { 12 | if (!host) return; 13 | host.setAttribute("aria-busy", "false"); 14 | host.addEventListener("scroll", onscroll, { once: true }); 15 | } 16 | host.addEventListener("scroll", onscroll, { once: true }); 17 | host.addEventListener("scrollend", onscrollend); 18 | return () => { 19 | host.removeEventListener("scroll", onscroll); 20 | host.removeEventListener("scrollend", onscrollend); 21 | }; 22 | }, [host]); 23 | } 24 | -------------------------------------------------------------------------------- /react-aria-carousel/src/CarouselItem.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentPropsWithoutRef, forwardRef, useContext } from "react"; 2 | 3 | import { IndexContext, useCarouselContext } from "./context"; 4 | import { useCarouselItem } from "./useCarouselItem"; 5 | import { mergeProps } from "./utils"; 6 | 7 | export interface CarouselItemProps extends ComponentPropsWithoutRef<"div"> { 8 | /** The placement of the item in the carousel */ 9 | index?: number; 10 | } 11 | 12 | export const CarouselItem = forwardRef( 13 | function CarouselItem({ index, ...props }, forwardedRef) { 14 | const ctx = useCarouselContext(); 15 | const itemIndex = useContext(IndexContext); 16 | 17 | const { itemProps } = useCarouselItem( 18 | { index: index ?? itemIndex }, 19 | ctx.carouselState, 20 | ); 21 | 22 | return
; 23 | }, 24 | ); 25 | -------------------------------------------------------------------------------- /react-aria-carousel/src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | useCarousel, 3 | type CarouselAria, 4 | type CarouselOptions, 5 | } from "./useCarousel"; 6 | export { useCarouselTab, type CarouselTabOptions } from "./useCarouselTab"; 7 | export { 8 | useCarouselItem, 9 | type CarouselItemAria, 10 | type CarouselItemOptions, 11 | } from "./useCarouselItem"; 12 | 13 | export { Carousel, type CarouselProps } from "./Carousel"; 14 | export { CarouselTabs, CarouselTab } from "./CarouselTabs"; 15 | export type { CarouselTabListProps, CarouselTabProps } from "./CarouselTabs"; 16 | export { CarouselButton, type CarouselButtonProps } from "./CarouselButton"; 17 | export { 18 | CarouselScroller, 19 | type CarouselScrollerProps, 20 | } from "./CarouselScroller"; 21 | 22 | export { 23 | CarouselAutoplayControl, 24 | type CarouselAutoplayControlProps, 25 | } from "./CarouselAutoplayControl"; 26 | export { CarouselItem } from "./CarouselItem"; 27 | -------------------------------------------------------------------------------- /react-aria-carousel/src/CarouselButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ComponentPropsWithoutRef, forwardRef } from "react"; 4 | import { mergeProps } from "@react-aria/utils"; 5 | 6 | import { useCarouselContext } from "./context"; 7 | 8 | export interface CarouselButtonProps 9 | extends Omit, "dir"> { 10 | /** Direction that the carousel should scroll when clicked. */ 11 | dir: "next" | "prev"; 12 | } 13 | 14 | export const CarouselButton = forwardRef< 15 | HTMLButtonElement, 16 | CarouselButtonProps 17 | >(function CarouselButton({ dir, ...props }, forwardedRef) { 18 | const { carouselState } = useCarouselContext(); 19 | 20 | const buttonProps = 21 | dir === "prev" 22 | ? carouselState?.prevButtonProps 23 | : carouselState?.nextButtonProps; 24 | 25 | return ( 26 | 33 | ); 34 | }); 35 | -------------------------------------------------------------------------------- /site/components/Image.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentPropsWithoutRef } from "react"; 2 | import a from "@/assets/images/1.webp"; 3 | import b from "@/assets/images/2.webp"; 4 | import c from "@/assets/images/3.webp"; 5 | import d from "@/assets/images/4.webp"; 6 | import e from "@/assets/images/5.webp"; 7 | import f from "@/assets/images/6.webp"; 8 | import g from "@/assets/images/7.webp"; 9 | import h from "@/assets/images/8.webp"; 10 | import i from "@/assets/images/9.webp"; 11 | import Image from "next/image"; 12 | 13 | import { css } from "@/styled-system/css"; 14 | 15 | const images = [a, b, c, d, e, f, g, h, i]; 16 | 17 | export function StockPhoto({ 18 | index, 19 | ...props 20 | }: { index: number } & Omit< 21 | ComponentPropsWithoutRef, 22 | "src" | "alt" 23 | >) { 24 | return ( 25 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /react-aria-carousel/stories/styles.css: -------------------------------------------------------------------------------- 1 | .root { 2 | display: grid; 3 | grid-template-areas: "tabs buttons" "scroller scroller"; 4 | grid-template-rows: min-content 1fr; 5 | gap: 16px; 6 | max-width: 600px; 7 | } 8 | .root img { 9 | margin: 0; 10 | } 11 | .scroller { 12 | grid-area: scroller; 13 | display: grid; 14 | overflow: auto; 15 | scroll-snap-type: x mandatory; 16 | grid-auto-flow: column; 17 | scrollbar-width: none; 18 | aspect-ratio: 3 / 2; 19 | } 20 | .item { 21 | aspect-ratio: 3 / 2; 22 | display: flex; 23 | justify-content: center; 24 | align-items: center; 25 | font-size: var(--font-sizes-2xl); 26 | } 27 | .buttons { 28 | grid-area: buttons; 29 | display: flex; 30 | justify-content: flex-end; 31 | gap: 8px; 32 | } 33 | .tabs { 34 | grid-area: tabs; 35 | display: flex; 36 | gap: 16px; 37 | } 38 | .tab[aria-selected="true"] { 39 | background-color: darkgrey; 40 | } 41 | .button, 42 | .tab { 43 | background-color: var(--colors-gray-200); 44 | color: var(--colors-gray-900); 45 | font-size: var(--font-sizes-sm); 46 | border-radius: var(--radii-md); 47 | padding: 0 var(--spacing-2); 48 | } 49 | .button[aria-disabled] { 50 | opacity: 0.5; 51 | } 52 | -------------------------------------------------------------------------------- /site/next.config.mjs: -------------------------------------------------------------------------------- 1 | import createMDX from "@next/mdx"; 2 | import rehypePrettyCode from "rehype-pretty-code"; 3 | import rehypeSlug from "rehype-slug"; 4 | import remarkGfm from "remark-gfm"; 5 | 6 | /** @type {import('next').NextConfig} */ 7 | const nextConfig = { 8 | pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"], 9 | cleanDistDir: true, 10 | typescript: { 11 | ignoreBuildErrors: true, 12 | }, 13 | poweredByHeader: false, 14 | env: { 15 | NEXT_TELEMETRY_DISABLED: "1", 16 | }, 17 | webpack: (config) => { 18 | config.module.rules.push({ 19 | resourceQuery: /raw/, 20 | loader: "raw-loader", 21 | }); 22 | 23 | return config; 24 | }, 25 | }; 26 | 27 | /** @type {import('rehype-pretty-code').Options} */ 28 | const rehypePrettyCodeOptions = { 29 | theme: { light: "github-light", dark: "github-dark-dimmed" }, 30 | keepBackground: false, 31 | // See Options section below. 32 | }; 33 | 34 | const withMDX = createMDX({ 35 | // Add markdown plugins here, as desired 36 | options: { 37 | remarkPlugins: [remarkGfm], 38 | rehypePlugins: [[rehypePrettyCode, rehypePrettyCodeOptions], rehypeSlug], 39 | }, 40 | }); 41 | 42 | // Merge MDX config with Next.js config 43 | export default withMDX(nextConfig); 44 | -------------------------------------------------------------------------------- /react-aria-carousel/src/useCarouselTab.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { CarouselAria } from "./useCarousel"; 4 | import { Attributes } from "./utils"; 5 | 6 | export interface CarouselTabOptions { 7 | /** An index of a page in the carousel. */ 8 | index: number; 9 | /** Whether the page is the active, visible page of the carousel. */ 10 | isSelected?: boolean; 11 | } 12 | 13 | export interface CarouselTabAria 14 | extends Readonly>> { 15 | /** Props for the tab element. */ 16 | readonly tabProps: Attributes<"button">; 17 | } 18 | 19 | export function useCarouselTab( 20 | props: CarouselTabOptions, 21 | state: CarouselAria, 22 | ): CarouselTabAria { 23 | const isSelected = props.isSelected ?? state.activePageIndex === props.index; 24 | let current = props.index + 1, 25 | setSize = state.pages.length; 26 | return { 27 | tabProps: { 28 | "data-carousel-tab": props.index, 29 | role: "tab", 30 | "aria-label": `Go to page ${current} of ${setSize}`, 31 | "aria-posinset": current, 32 | "aria-setsize": setSize, 33 | "aria-selected": isSelected, 34 | tabIndex: isSelected ? 0 : -1, 35 | onClick: () => state.scrollToPage(props.index), 36 | }, 37 | isSelected, 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /react-aria-carousel/panda.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "@pandacss/dev"; 2 | 3 | export default defineConfig({ 4 | // Whether to use css reset 5 | preflight: true, 6 | 7 | // Where to look for your css declarations 8 | include: ["./stories/**/*.{js,jsx,ts,tsx}"], 9 | 10 | // Files to exclude 11 | exclude: [], 12 | 13 | // Useful for theme customization 14 | theme: { 15 | extend: {}, 16 | }, 17 | 18 | utilities: { 19 | extend: { 20 | size: { 21 | transform(value) { 22 | return { height: value, width: value }; 23 | }, 24 | group: "Width", 25 | values: "sizes", 26 | }, 27 | }, 28 | }, 29 | 30 | // globalCss: { 31 | // extend: { 32 | // ".prose": { 33 | // "& > * + *": { 34 | // mt: "4", 35 | // }, 36 | // li: { mb: "2" }, 37 | // h1: { textStyle: "5xl", mt: "6" }, 38 | // h2: { textStyle: "4xl", mt: "6" }, 39 | // h3: { textStyle: "3xl", mt: "6" }, 40 | // h4: { textStyle: "2xl", mt: "6" }, 41 | // "ul li": { listStyleType: "disc" }, 42 | // "ol li": { listStyleType: "" }, 43 | // "ol, ul": { ml: "6" }, 44 | // }, 45 | // }, 46 | // }, 47 | 48 | // The output directory for your css system 49 | outdir: "styled-system", 50 | }); 51 | -------------------------------------------------------------------------------- /site/app/styles.css: -------------------------------------------------------------------------------- 1 | @layer reset, base, tokens, recipes, utilities; 2 | 3 | *, 4 | *::before, 5 | *::after { 6 | box-sizing: border-box; 7 | } 8 | 9 | .scrollContainer { 10 | display: grid; 11 | max-height: inherit; 12 | max-width: inherit; 13 | scrollbar-width: none; 14 | } 15 | 16 | .scrollContainer[data-orientation="horizontal"] { 17 | scroll-snap-type: x mandatory; 18 | grid-auto-flow: column; 19 | overflow-x: scroll; 20 | overflow-y: hidden; 21 | overscroll-behavior-x: contain; 22 | } 23 | 24 | .scrollContainer[data-orientation="vertical"] { 25 | scroll-snap-type: y mandatory; 26 | grid-auto-flow: row; 27 | overflow-x: hidden; 28 | overflow-y: scroll; 29 | overscroll-behavior-y: contain; 30 | } 31 | 32 | .scrollContainer::-webkit-scrollbar-track { 33 | background: transparent; 34 | } 35 | 36 | .scrollContainer::-webkit-scrollbar { 37 | appearance: none; 38 | width: 0; 39 | height: 0; 40 | } 41 | 42 | .scrollContainer::-webkit-scrollbar-thumb { 43 | background: transparent; 44 | border: none; 45 | } 46 | 47 | .item { 48 | overflow: hidden; 49 | } 50 | 51 | .flexContainer { 52 | display: flex; 53 | overflow: auto; 54 | overscroll-behavior: contain; 55 | scroll-snap-type: x mandatory; 56 | } 57 | 58 | .flexItem { 59 | width: 300px; 60 | height: 300px; 61 | max-width: 100%; 62 | flex-shrink: 0; 63 | } 64 | -------------------------------------------------------------------------------- /site/README.md: -------------------------------------------------------------------------------- 1 | For testing SSR. 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /site/examples/basic/Basic.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { StockPhoto } from "@/components/Image"; 4 | import { 5 | Carousel, 6 | CarouselButton, 7 | CarouselItem, 8 | CarouselScroller, 9 | CarouselTab, 10 | CarouselTabs, 11 | } from "react-aria-carousel"; 12 | 13 | import styles from "./styles.module.css"; 14 | 15 | export function Basic() { 16 | return ( 17 | 18 |
19 | 20 | Previous 21 | 22 | 23 | Next 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | {(item) => ( 34 | 35 | {item.index + 1} 36 | 37 | )} 38 | 39 |
40 | ); 41 | } 42 | 43 | function Item({ index }: { index: number }) { 44 | return ( 45 | 46 | 47 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /react-aria-carousel/src/useCarouselItem.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { CarouselAria } from "./useCarousel"; 4 | import { Attributes } from "./utils"; 5 | 6 | export interface CarouselItemOptions { 7 | /** The index placement of the item within the list of carousel items */ 8 | index: number; 9 | } 10 | 11 | export interface CarouselItemAria { 12 | /** Props for the item element */ 13 | readonly itemProps: Attributes<"div">; 14 | } 15 | 16 | export function useCarouselItem( 17 | props: CarouselItemOptions, 18 | state: CarouselAria, 19 | ): CarouselItemAria { 20 | const { index } = props; 21 | const { 22 | pages = [], 23 | activePageIndex, 24 | scrollBy = "page", 25 | itemsPerPage = 1, 26 | } = state; 27 | const actualItemsPerPage = Math.floor(itemsPerPage); 28 | const shouldSnap = 29 | scrollBy === "item" || 30 | (index! + actualItemsPerPage) % actualItemsPerPage === 0; 31 | const itemCount = new Set(pages?.flat()).size; 32 | const label = itemCount ? `${index! + 1} of ${itemCount}` : undefined; 33 | const isInert = pages?.[activePageIndex]?.includes(index!); 34 | 35 | return { 36 | itemProps: { 37 | "data-carousel-item": index, 38 | inert: isInert ? undefined : "true", 39 | "aria-hidden": isInert ? undefined : true, 40 | "aria-roledescription": "carousel item", 41 | role: "group", 42 | "aria-label": label, 43 | style: { 44 | scrollSnapAlign: shouldSnap ? "start" : undefined, 45 | }, 46 | }, 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /site/components/CodeDemo/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | import { CodeArea } from "./CodeArea"; 4 | import { highlightCode } from "./highlightCode"; 5 | import { css } from "@/styled-system/css"; 6 | 7 | export async function CodeDemo({ 8 | children, 9 | files, 10 | }: { 11 | children: ReactNode; 12 | files: { code: string; title: string; lang: string }[]; 13 | }) { 14 | let transformedFiles = []; 15 | for (let file of files) { 16 | const arr = file.code.split("\n"); 17 | if (arr[0].includes("use client")) { 18 | file.code = arr.slice(2, arr.length).join("\n"); 19 | } 20 | const code = await highlightCode(file); 21 | transformedFiles.push({ ...file, code, id: file.title }); 22 | } 23 | 24 | return ( 25 |
36 |
48 | {children} 49 |
50 |
51 | 52 |
53 |
54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /react-aria-carousel/src/Carousel.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ComponentPropsWithoutRef, forwardRef } from "react"; 4 | 5 | import { Context } from "./context"; 6 | import { CarouselOptions, useCarousel } from "./useCarousel"; 7 | import { mergeProps } from "./utils"; 8 | 9 | export interface CarouselProps 10 | extends Omit, 11 | ComponentPropsWithoutRef<"div"> {} 12 | 13 | export const Carousel = forwardRef( 14 | function Carousel( 15 | { 16 | children, 17 | spaceBetweenItems = "16px", 18 | scrollPadding, 19 | mouseDragging, 20 | autoplay, 21 | autoplayInterval, 22 | itemsPerPage = 1, 23 | loop, 24 | orientation = "horizontal", 25 | scrollBy = "page", 26 | initialPages = [], 27 | onActivePageIndexChange, 28 | ...props 29 | }, 30 | ref, 31 | ) { 32 | const carouselProps = { 33 | spaceBetweenItems, 34 | scrollPadding, 35 | mouseDragging, 36 | autoplay, 37 | autoplayInterval, 38 | itemsPerPage, 39 | loop, 40 | orientation, 41 | scrollBy, 42 | initialPages, 43 | onActivePageIndexChange, 44 | }; 45 | const [assignRef, carouselState] = useCarousel(carouselProps); 46 | 47 | return ( 48 | 55 |
56 | {children} 57 |
58 |
59 | ); 60 | }, 61 | ); 62 | -------------------------------------------------------------------------------- /react-aria-carousel/tests/ComposedCarousel.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Carousel, 3 | CarouselAutoplayControl, 4 | CarouselButton, 5 | CarouselItem, 6 | CarouselProps, 7 | CarouselScroller, 8 | CarouselTab, 9 | CarouselTabs, 10 | } from "../src"; 11 | 12 | export function ComposedCarousel(props: CarouselProps) { 13 | return ( 14 | 15 |
16 | 17 | Disable autoplay 18 | 19 | 20 | Previous 21 | 22 | 23 | Next 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | {(item) => ( 34 | 35 | {item.index + 1} 36 | 37 | )} 38 | 39 |
40 | ); 41 | } 42 | 43 | const colors = [ 44 | "sky", 45 | "red", 46 | "green", 47 | "blue", 48 | "orange", 49 | "violet", 50 | "lime", 51 | "fuchsia", 52 | "purple", 53 | "pink", 54 | "rose", 55 | ]; 56 | 57 | function Item({ index }: { index: number }) { 58 | return ( 59 | 66 | {index + 1} 67 | 68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /site/examples/multiple-items/MultipleItems.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Carousel, 5 | CarouselButton, 6 | CarouselItem, 7 | CarouselScroller, 8 | CarouselTab, 9 | CarouselTabs, 10 | } from "react-aria-carousel"; 11 | import { FaChevronLeft, FaChevronRight } from "react-icons/fa6"; 12 | 13 | import styles from "./styles.module.css"; 14 | 15 | export function MultipleItems() { 16 | return ( 17 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | {(page) => } 39 | 40 | 41 | ); 42 | } 43 | 44 | const colors = [ 45 | "sky", 46 | "red", 47 | "green", 48 | "blue", 49 | "orange", 50 | "violet", 51 | "lime", 52 | "fuchsia", 53 | "purple", 54 | "pink", 55 | "rose", 56 | ]; 57 | 58 | function Item({ index }: { index: number }) { 59 | return ( 60 | 66 | {index + 1} 67 | 68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /site/hooks/useMatchMedia.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useLayoutEffect, useState } from "react"; 2 | 3 | const useIsomorphicLayoutEffect = 4 | typeof window === "undefined" ? useEffect : useLayoutEffect; 5 | 6 | type UseMediaQueryOptions = { 7 | defaultValue?: boolean; 8 | initializeWithValue?: boolean; 9 | }; 10 | 11 | const IS_SERVER = typeof window === "undefined"; 12 | 13 | export function useMediaQuery( 14 | query: string, 15 | { 16 | defaultValue = false, 17 | initializeWithValue = true, 18 | }: UseMediaQueryOptions = {}, 19 | ): boolean { 20 | const getMatches = (query: string): boolean => { 21 | if (IS_SERVER) { 22 | return defaultValue; 23 | } 24 | return window.matchMedia(query).matches; 25 | }; 26 | 27 | const [matches, setMatches] = useState(() => { 28 | if (initializeWithValue) { 29 | return getMatches(query); 30 | } 31 | return defaultValue; 32 | }); 33 | 34 | // Handles the change event of the media query. 35 | function handleChange() { 36 | setMatches(getMatches(query)); 37 | } 38 | 39 | useIsomorphicLayoutEffect(() => { 40 | const matchMedia = window.matchMedia(query); 41 | 42 | // Triggered at the first client-side load and if query changes 43 | handleChange(); 44 | 45 | // Use deprecated `addListener` and `removeListener` to support Safari < 14 46 | if (matchMedia.addListener) { 47 | matchMedia.addListener(handleChange); 48 | } else { 49 | matchMedia.addEventListener("change", handleChange); 50 | } 51 | 52 | return () => { 53 | if (matchMedia.removeListener) { 54 | matchMedia.removeListener(handleChange); 55 | } else { 56 | matchMedia.removeEventListener("change", handleChange); 57 | } 58 | }; 59 | }, [query]); 60 | 61 | return matches; 62 | } 63 | -------------------------------------------------------------------------------- /site/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "type-check": "tsc" 11 | }, 12 | "dependencies": { 13 | "scrollyfills": "^1", 14 | "@af-utils/scrollend-polyfill": "0.0.13", 15 | "@pandacss/dev": "^0.39.1", 16 | "react-aria-carousel": "workspace:*", 17 | "next": "14.2.3", 18 | "react": "^18", 19 | "react-dom": "^18", 20 | "react-live": "^4.1.6", 21 | "remark-gfm": "^4.0.0", 22 | "react-collapsed": "^4", 23 | "rehype-stringify": "^10", 24 | "react-aria-components": "^1.2.0", 25 | "@fontsource/ibm-plex-sans": "latest", 26 | "@fontsource/ibm-plex-mono": "latest" 27 | }, 28 | "devDependencies": { 29 | "@mdx-js/loader": "^3.0.1", 30 | "@mdx-js/react": "^3.0.1", 31 | "@next/mdx": "^14.2.3", 32 | "@radix-ui/colors": "^3", 33 | "@types/mdx": "^2.0.13", 34 | "@types/node": "^20", 35 | "@types/react": "^18", 36 | "@types/react-dom": "^18", 37 | "@typescript-eslint/eslint-plugin": "^7.5.0", 38 | "@typescript-eslint/parser": "^7.5.0", 39 | "eslint": "^8.57.0", 40 | "eslint-config-next": "^14.2.3", 41 | "eslint-plugin-react": "^7.34.1", 42 | "eslint-plugin-react-hooks": "^4.6.0", 43 | "eslint-plugin-react-refresh": "^0.4.6", 44 | "eslint-plugin-storybook": "^0.8.0", 45 | "pandacss-preset-radix-colors": "^0.2.0", 46 | "pandacss-preset-typography": "^0.1.5", 47 | "raw-loader": "^4.0.2", 48 | "rehype-highlight": "^7.0.0", 49 | "rehype-pretty-code": "^0.13.1", 50 | "shiki": "^1.5.1", 51 | "smoothscroll-polyfill": "^0.4.4", 52 | "typescript": "^5", 53 | "typescript-plugin-css-modules": "^4.1.0" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /site/examples/mouse-dragging/MouseDragging.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Carousel, 5 | CarouselButton, 6 | CarouselItem, 7 | CarouselScroller, 8 | CarouselTab, 9 | CarouselTabs, 10 | } from "react-aria-carousel"; 11 | import { FaChevronLeft, FaChevronRight } from "react-icons/fa6"; 12 | 13 | import styles from "./styles.module.css"; 14 | 15 | export function MouseDragging() { 16 | return ( 17 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | {(page) => } 40 | 41 | 42 | ); 43 | } 44 | 45 | const colors = [ 46 | "sky", 47 | "red", 48 | "green", 49 | "blue", 50 | "orange", 51 | "violet", 52 | "lime", 53 | "fuchsia", 54 | "purple", 55 | "pink", 56 | "rose", 57 | ]; 58 | 59 | function Item({ index }: { index: number }) { 60 | return ( 61 | 67 | {index + 1} 68 | 69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /site/examples/orientation/Orientation.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Carousel, 5 | CarouselButton, 6 | CarouselItem, 7 | CarouselScroller, 8 | CarouselTab, 9 | CarouselTabs, 10 | } from "react-aria-carousel"; 11 | import { FaChevronLeft, FaChevronRight } from "react-icons/fa6"; 12 | 13 | import styles from "./styles.module.css"; 14 | 15 | export function Orientation() { 16 | return ( 17 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | {(page) => } 40 | 41 | 42 | ); 43 | } 44 | 45 | const colors = [ 46 | "sky", 47 | "red", 48 | "green", 49 | "blue", 50 | "orange", 51 | "violet", 52 | "lime", 53 | "fuchsia", 54 | "purple", 55 | "pink", 56 | "rose", 57 | ]; 58 | 59 | function Item({ index }: { index: number }) { 60 | return ( 61 | 67 | {index + 1} 68 | 69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /react-aria-carousel/src/CarouselTabs.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | ComponentPropsWithoutRef, 5 | forwardRef, 6 | Fragment, 7 | ReactNode, 8 | } from "react"; 9 | import { mergeProps } from "@react-aria/utils"; 10 | 11 | import { useCarouselContext } from "./context"; 12 | import { CarouselTabOptions, useCarouselTab } from "./useCarouselTab"; 13 | 14 | export interface CarouselTabListProps 15 | extends Omit, "children"> { 16 | /** Function that returns a CarouselTab. */ 17 | children: (props: { isSelected: boolean; index: number }) => ReactNode; 18 | } 19 | 20 | export const CarouselTabs = forwardRef( 21 | function CarouselTabs({ children, ...props }, forwardedRef) { 22 | const { carouselState } = useCarouselContext(); 23 | 24 | return ( 25 |
29 | {carouselState?.pages.map((_, index) => ( 30 | 31 | {children({ 32 | isSelected: index === carouselState?.activePageIndex, 33 | index, 34 | })} 35 | 36 | ))} 37 |
38 | ); 39 | }, 40 | ); 41 | 42 | export interface CarouselTabProps 43 | extends CarouselTabOptions, 44 | ComponentPropsWithoutRef<"button"> {} 45 | 46 | export const CarouselTab = forwardRef( 47 | function CarouselTab(props, forwardedRef) { 48 | const { carouselState } = useCarouselContext(); 49 | const { index } = props; 50 | const { tabProps } = useCarouselTab({ index }, carouselState!); 51 | return ( 52 |
63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | {data 73 | .sort((a, b) => (a.name < b.name ? -1 : 1)) 74 | .map((prop) => ( 75 | 76 | 79 | 82 | 85 | 94 | 95 | ))} 96 | 97 |
NameTypeDefaultDescription
77 | {prop.name} 78 | 80 | {prop.type} 81 | 83 | {prop.defaultValue} 84 | 92 | {prop.description.trim()} 93 |
98 | 99 | ); 100 | } 101 | -------------------------------------------------------------------------------- /react-aria-carousel/src/utils/useAutoplay.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ComponentPropsWithoutRef, 3 | Dispatch, 4 | SetStateAction, 5 | useCallback, 6 | useEffect, 7 | useLayoutEffect, 8 | useRef, 9 | useState, 10 | } from "react"; 11 | 12 | const useIsomorphicLayoutEffect = 13 | typeof window !== "undefined" ? useLayoutEffect : useEffect; 14 | 15 | interface AutoplayOptions { 16 | enabled: boolean; 17 | interval: number; 18 | next: () => number; 19 | } 20 | 21 | interface Autoplay { 22 | shouldAutoplay: boolean; 23 | autoplayUserPreference: boolean; 24 | setAutoplayUserPreference: Dispatch>; 25 | rootProps: ComponentPropsWithoutRef<"div">; 26 | } 27 | 28 | export function useAutoplay({ 29 | enabled, 30 | interval, 31 | next, 32 | }: AutoplayOptions): Autoplay { 33 | // Controls whether autoplaying is active, dependent on being enabled by props 34 | // and whether the user is interacting with the Carousel. 35 | const [shouldAutoplay, setShouldAutoplay] = useState(enabled); 36 | // Controls whether the user has directly disabled autoplay 37 | const [autoplayUserPreference, setAutoplayUserPreference] = useState(enabled); 38 | 39 | useInterval( 40 | () => requestAnimationFrame(next), 41 | autoplayUserPreference && shouldAutoplay ? interval : null, 42 | ); 43 | 44 | const pause = useCallback(() => { 45 | if (!enabled) return; 46 | setShouldAutoplay(false); 47 | }, [enabled]); 48 | const play = useCallback(() => { 49 | if (!enabled) return; 50 | setShouldAutoplay(true); 51 | }, [enabled]); 52 | 53 | useEffect(() => { 54 | function listener() { 55 | if (document.visibilityState === "hidden") { 56 | pause(); 57 | } else { 58 | play(); 59 | } 60 | } 61 | document.addEventListener("visibilitychange", listener); 62 | 63 | return () => { 64 | document.removeEventListener("visibilitychange", listener); 65 | }; 66 | }, [pause, play]); 67 | 68 | return { 69 | shouldAutoplay, 70 | autoplayUserPreference, 71 | setAutoplayUserPreference, 72 | rootProps: { 73 | onMouseEnter: pause, 74 | onTouchStart: pause, 75 | onFocus: pause, 76 | onMouseLeave: play, 77 | onTouchEnd: play, 78 | onBlur: play, 79 | }, 80 | }; 81 | } 82 | 83 | export function useInterval(callback: () => void, delay: number | null) { 84 | const savedCallback = useRef(callback); 85 | 86 | // Remember the latest callback if it changes. 87 | useIsomorphicLayoutEffect(() => { 88 | savedCallback.current = callback; 89 | }, [callback]); 90 | 91 | // Set up the interval. 92 | useEffect(() => { 93 | // Don't schedule if no delay is specified. 94 | // Note: 0 is a valid value for delay. 95 | if (delay === null) { 96 | return; 97 | } 98 | 99 | const id = setInterval(() => { 100 | savedCallback.current(); 101 | }, delay); 102 | 103 | return () => { 104 | clearInterval(id); 105 | }; 106 | }, [delay]); 107 | } 108 | -------------------------------------------------------------------------------- /site/app/scrollend-polyfill.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | type GenericCallback = (this: unknown, ...args: unknown[]) => unknown; 4 | 5 | type MethodOf = T[K] extends GenericCallback 6 | ? T[K] 7 | : never; 8 | 9 | const debounce = (fn: T, delay: number) => { 10 | let timerId = 0; 11 | 12 | return function (this: unknown, ...args: unknown[]) { 13 | window.clearTimeout(timerId); 14 | timerId = window.setTimeout(() => { 15 | fn.call(this, ...args); 16 | }, delay); 17 | }; 18 | }; 19 | 20 | const decorate = ( 21 | proto: T, 22 | method: M, 23 | decorateFn: (this: unknown, superFn: T[M], ...args: unknown[]) => unknown, 24 | ) => { 25 | const superFn = proto[method] as MethodOf; 26 | 27 | proto[method] = function (this: unknown, ...args: unknown[]) { 28 | superFn.call(this, ...args); 29 | decorateFn.call(this, superFn, ...args); 30 | } as MethodOf; 31 | }; 32 | 33 | export const scrollEndPolyfill = () => { 34 | const isSupported = "onscrollend" in window; 35 | 36 | if (!isSupported) { 37 | const pointers = new Set(); 38 | const scrollHandlers = new WeakMap< 39 | EventTarget, 40 | EventListenerOrEventListenerObject 41 | >(); 42 | 43 | const handlePointerDown = (event: TouchEvent) => { 44 | for (const touch of event.changedTouches) { 45 | pointers.add(touch.identifier); 46 | } 47 | }; 48 | 49 | const handlePointerUp = (event: TouchEvent) => { 50 | for (const touch of event.changedTouches) { 51 | pointers.delete(touch.identifier); 52 | } 53 | }; 54 | 55 | document.addEventListener("touchstart", handlePointerDown, true); 56 | document.addEventListener("touchend", handlePointerUp, true); 57 | document.addEventListener("touchcancel", handlePointerUp, true); 58 | 59 | decorate( 60 | EventTarget.prototype, 61 | "addEventListener", 62 | function (this: EventTarget, addEventListener, type) { 63 | if (type !== "scrollend") return; 64 | 65 | const handleScrollEnd = debounce(() => { 66 | if (!pointers.size) { 67 | // If no pointer is active in the scroll area then the scroll has ended 68 | this.dispatchEvent(new Event("scrollend")); 69 | } else { 70 | // otherwise let's wait a bit more 71 | handleScrollEnd(); 72 | } 73 | }, 100); 74 | 75 | addEventListener.call(this, "scroll", handleScrollEnd, { 76 | passive: true, 77 | }); 78 | scrollHandlers.set(this, handleScrollEnd); 79 | }, 80 | ); 81 | 82 | decorate( 83 | EventTarget.prototype, 84 | "removeEventListener", 85 | function (this: EventTarget, removeEventListener, type) { 86 | if (type !== "scrollend") return; 87 | 88 | const scrollHandler = scrollHandlers.get(this); 89 | if (scrollHandler) { 90 | removeEventListener.call(this, "scroll", scrollHandler, { 91 | passive: true, 92 | } as unknown as EventListenerOptions); 93 | } 94 | }, 95 | ); 96 | } 97 | }; 98 | -------------------------------------------------------------------------------- /site/examples/styles.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | display: grid; 3 | grid-template-areas: ". scroller ." ". tabs ."; 4 | grid-auto-columns: min-content 1fr min-content; 5 | grid-auto-rows: 1fr min-content; 6 | column-gap: var(--spacing-6); 7 | position: relative; 8 | align-items: center; 9 | flex-grow: 1; 10 | } 11 | .root[data-orientation="vertical"] { 12 | grid-template-areas: ". scroller" "tabs scroller" ". scroller"; 13 | grid-auto-columns: min-content 1fr; 14 | grid-auto-rows: min-content 1fr min-content; 15 | } 16 | .button { 17 | background-color: transparent; 18 | border: 0; 19 | height: 36px; 20 | width: 36px; 21 | font-size: 1.25rem; 22 | display: flex; 23 | justify-content: center; 24 | align-items: center; 25 | transition: all ease 0.2s; 26 | border-radius: 9999px; 27 | transform: translateX(var(--translateX)) scale(var(--scale)) 28 | rotate(var(--rotate)); 29 | } 30 | .button:not([disabled]):hover { 31 | --scale: 1.05; 32 | color: var(--colors-blue-700); 33 | box-shadow: 0 0 0 4px var(--colors-gray-200); 34 | } 35 | [data-orientation="vertical"] .button { 36 | rotate: 90deg; 37 | } 38 | .button[data-dir="prev"]:not([disabled]):hover { 39 | --translateX: -5%; 40 | } 41 | .button[data-dir="next"]:not([disabled]):hover { 42 | --translateX: 5%; 43 | } 44 | .button[disabled] { 45 | opacity: 0.5; 46 | } 47 | .scroller { 48 | grid-area: scroller; 49 | display: grid; 50 | scrollbar-width: none; 51 | } 52 | .scroller[data-orientation="horizontal"] { 53 | scroll-snap-type: x mandatory; 54 | grid-auto-flow: column; 55 | overflow-x: auto; 56 | overflow-y: hidden; 57 | } 58 | .scroller[data-orientation="vertical"] { 59 | scroll-snap-type: y mandatory; 60 | grid-auto-flow: row; 61 | overflow-y: auto; 62 | overflow-x: hidden; 63 | aspect-ratio: var(--aspect-ratio); 64 | } 65 | .item { 66 | aspect-ratio: 16 / 9; 67 | } 68 | .tabs { 69 | grid-area: tabs; 70 | display: flex; 71 | gap: 16px; 72 | align-items: center; 73 | justify-content: center; 74 | } 75 | [data-orientation="horizontal"] .tabs { 76 | margin-top: 24px; 77 | } 78 | [data-orientation="vertical"] .tabs { 79 | flex-direction: column; 80 | } 81 | .tab { 82 | border-radius: var(--radii-full); 83 | height: var(--sizes-4); 84 | width: var(--sizes-4); 85 | background-color: var(--colors-gray-300); 86 | transition: background-color 0.2s ease; 87 | } 88 | .tab:hover { 89 | background-color: var(--colors-gray-500); 90 | } 91 | .tab[aria-selected="true"] { 92 | background-color: var(--colors-gray-700); 93 | } 94 | .root img { 95 | margin: 0; 96 | } 97 | .stockItem { 98 | color: var(--colors-prose-body); 99 | display: flex; 100 | justify-content: center; 101 | align-items: center; 102 | font-size: var(--font-sizes-2xl); 103 | border-radius: var(--radii-3xl); 104 | } 105 | 106 | @media screen and (max-width: 800px) { 107 | .root { 108 | gap: 8px; 109 | } 110 | .button { 111 | width: 24px; 112 | height: 24px; 113 | } 114 | [data-orientation="horizontal"] .tabs { 115 | margin-top: 12px; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /react-aria-carousel/src/utils/mergeProps.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | interface Props { 14 | [key: string]: any; 15 | } 16 | 17 | type PropsArg = Props | null | undefined; 18 | 19 | // taken from: https://stackoverflow.com/questions/51603250/typescript-3-parameter-list-intersection-type/51604379#51604379 20 | type TupleTypes = { [P in keyof T]: T[P] } extends { [key: number]: infer V } 21 | ? NullToObject 22 | : never; 23 | type NullToObject = T extends null | undefined ? object : T; 24 | // eslint-disable-next-line no-undef, @typescript-eslint/no-unused-vars 25 | type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( 26 | k: infer I, 27 | ) => void 28 | ? I 29 | : never; 30 | 31 | /** 32 | * Merges multiple props objects together. Event handlers are chained, 33 | * classNames are combined, and ids are deduplicated - different ids 34 | * will trigger a side-effect and re-render components hooked up with `useId`. 35 | * For all other props, the last prop object overrides all previous ones. 36 | * @param args - Multiple sets of props to merge together. 37 | */ 38 | export function mergeProps( 39 | ...args: T 40 | ): UnionToIntersection> { 41 | // Start with a base clone of the first argument. This is a lot faster than starting 42 | // with an empty object and adding properties as we go. 43 | let result: Props = { ...args[0] }; 44 | for (let i = 1; i < args.length; i++) { 45 | let props = args[i]; 46 | for (let key in props) { 47 | let a = result[key]; 48 | let b = props[key]; 49 | 50 | // Chain events 51 | if ( 52 | typeof a === "function" && 53 | typeof b === "function" && 54 | // This is a lot faster than a regex. 55 | key[0] === "o" && 56 | key[1] === "n" && 57 | key.charCodeAt(2) >= /* 'A' */ 65 && 58 | key.charCodeAt(2) <= /* 'Z' */ 90 59 | ) { 60 | result[key] = chain(a, b); 61 | 62 | // Merge classnames, sometimes classNames are empty string which eval to false, so we just need to do a type check 63 | } else if ( 64 | key === "className" && 65 | typeof a === "string" && 66 | typeof b === "string" 67 | ) { 68 | result[key] = [a, b].join(); 69 | // Override others 70 | } else { 71 | result[key] = b !== undefined ? b : a; 72 | } 73 | } 74 | } 75 | 76 | return result as UnionToIntersection>; 77 | } 78 | 79 | /** 80 | * Calls all functions in the order they were chained with the same arguments. 81 | */ 82 | function chain(...callbacks: any[]): (...args: any[]) => void { 83 | return (...args: any[]) => { 84 | for (let callback of callbacks) { 85 | if (typeof callback === "function") { 86 | callback(...args); 87 | } 88 | } 89 | }; 90 | } 91 | -------------------------------------------------------------------------------- /react-aria-carousel/readme.md: -------------------------------------------------------------------------------- 1 | # 🎠 React Aria Carousel 2 | 3 | > The carousel for the modern age. 4 | 5 | **React Aria Carousel** is a collection of React hooks and components that provide the behavior and accessibility implementation for building a carousel. Unlike many other carousels, this implementation uses the latest browser and DOM APIs for scrolling and snapping. 6 | 7 | Checkout documentation and examples at https://react-aria-carousel.vercel.app. 8 | 9 | ## Features 10 | 11 | - Native **browser scroll-snapping and smooth-scrolling** for performant transitions across pointer types. 12 | - **Comprehensive behavior and accessibility coverage** for all elements of a carousel, including pagination, infinite scrolling, autoplay, and more. 13 | - Support for showing **one or many items per page**, implemented with responsive-design. 14 | - Support for **vertical and horizontal** scrolling. 15 | - Support for **infinite scrolling**. 16 | - Support for **autoplay** with affordances for disabling it. 17 | - Support for **mouse dragging**. 18 | - Written in **TypeScript**. 19 | 20 | ## Installation 21 | 22 | ```sh 23 | npm install react-aria-carousel 24 | ``` 25 | 26 | ## Usage 27 | 28 | `react-aria-carousel` comes with both ready-to-go unstyled React components and hooks if you need more control over how the component is rendered. 29 | 30 | A simple set-up using the components: 31 | 32 | 33 | ```tsx 34 | import { 35 | Carousel, 36 | CarouselButton, 37 | CarouselButton, 38 | CarouselItem, 39 | CarouselScroller, 40 | CarouselTab, 41 | CarouselTabs, 42 | } from "react-aria-carousel"; 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | {(page) => ( 53 | 54 | )} 55 | 56 | 57 | ``` 58 | 59 | And a simple set-up using the hooks: 60 | 61 | ```tsx 62 | import { 63 | useCarousel, 64 | useCarouselItem, 65 | useCarouselTab, 66 | } from "react-aria-carousel"; 67 | 68 | export function Carousel() { 69 | const [assignScrollerRef, carousel] = useCarousel({ 70 | spaceBetweenItems: "16px", 71 | itemsPerPage: 2, 72 | }); 73 | 74 | const { 75 | rootProps, 76 | prevButtonProps, 77 | nextButtonProps, 78 | scrollerProps, 79 | tabProps, 80 | pages, 81 | autoplayControlProps, 82 | } = carousel; 83 | 84 | return ( 85 |
86 | 87 |
88 | 89 | 90 |
91 |
92 | 93 |
94 |
95 | {pages.map((_, i) => ( 96 | 97 | ))} 98 |
99 |
100 | ); 101 | } 102 | 103 | function CarouselItem({ index, state }) { 104 | const { itemProps } = useCarouselItem({ index }, state); 105 | return
; 106 | } 107 | 108 | function CarouselTab({ index, state }) { 109 | const { tabProps } = useCarouselTab({ index }, state); 110 | return
; 111 | } 112 | ``` 113 | -------------------------------------------------------------------------------- /site/app/header.tsx: -------------------------------------------------------------------------------- 1 | import { flex, grid, visuallyHidden } from "@/styled-system/patterns"; 2 | import { FaChevronDown } from "react-icons/fa6"; 3 | import { PiArrowUpRight } from "react-icons/pi"; 4 | 5 | import { HeroCarousel } from "./HeroCarousel"; 6 | import { css } from "@/styled-system/css"; 7 | 8 | export function Header() { 9 | return ( 10 |
24 | 56 |
67 |

75 | React Aria Carousel 76 |

77 |

84 | The carousel for the modern age. 85 |

86 |
87 |
101 | 102 | 119 |
120 |
121 | ); 122 | } 123 | -------------------------------------------------------------------------------- /site/components/CodeDemo/CodeArea.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useRef, useState } from "react"; 4 | import { 5 | Collection, 6 | Tab, 7 | TabList, 8 | TabPanel, 9 | Tabs, 10 | } from "react-aria-components"; 11 | import { useCollapse } from "react-collapsed"; 12 | 13 | import { css } from "@/styled-system/css"; 14 | 15 | export function CodeArea({ 16 | files, 17 | collapsedHeight, 18 | }: { 19 | files: { code: string; title: string; lang: string }[]; 20 | collapsedHeight: number; 21 | filename?: string; 22 | }) { 23 | const ref = useRef(null); 24 | const [isExpanded, setExpanded] = useState(false); 25 | const { getCollapseProps, getToggleProps } = useCollapse({ 26 | isExpanded: isExpanded, 27 | collapsedHeight: collapsedHeight, 28 | onTransitionStateChange(state) { 29 | if (state === "collapsing" && ref.current) { 30 | ref.current.scrollIntoView({ 31 | behavior: "smooth", 32 | block: "start", 33 | }); 34 | } 35 | }, 36 | }); 37 | 38 | return ( 39 |
40 | 41 | 53 | {(file) => ( 54 | 72 | {file.title} 73 | 74 | )} 75 | 76 |
101 | 102 | {(file) => { 103 | return ( 104 | 105 |
106 | 107 | ); 108 | }} 109 | 110 |
111 |
112 | 135 |
136 | ); 137 | } 138 | -------------------------------------------------------------------------------- /site/panda.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "@pandacss/dev"; 2 | import radixColorsPreset from "pandacss-preset-radix-colors"; 3 | import typographyPreset from "pandacss-preset-typography"; 4 | 5 | export default defineConfig({ 6 | presets: [ 7 | radixColorsPreset({ 8 | darkMode: { condition: "@media (prefers-color-scheme: dark)" }, 9 | // colorScales: ["slate", "blue", "amber"], 10 | }), 11 | typographyPreset(), 12 | "@pandacss/dev/presets", 13 | ], 14 | 15 | // Whether to use css reset 16 | preflight: true, 17 | 18 | // Where to look for your css declarations 19 | include: ["./{app,docs,examples,components}/**/*.{js,jsx,ts,tsx,mdx}"], 20 | 21 | // Files to exclude 22 | exclude: [], 23 | 24 | // Useful for theme customization 25 | theme: { 26 | extend: { 27 | semanticTokens: { 28 | fonts: { 29 | body: { value: "'IBM Plex Sans', sans-serif" }, 30 | mono: { value: "'IBM Plex Mono', monospace" }, 31 | }, 32 | colors: { 33 | prose: { 34 | body: { 35 | value: "{colors.slate.12}", 36 | }, 37 | heading: { 38 | value: "{colors.slate.12}", 39 | }, 40 | lead: { 41 | value: "{colors.slate.12}", 42 | }, 43 | link: { 44 | value: "{colors.blue.11}", 45 | }, 46 | bold: { 47 | value: "{colors.slate.12}", 48 | }, 49 | counter: { 50 | value: "{colors.slate.11}", 51 | }, 52 | bullet: { 53 | value: "{colors.slate.11}", 54 | }, 55 | hrBorder: { 56 | value: "{colors.indigo.4}", 57 | }, 58 | quote: { 59 | value: "{colors.slate.11}", 60 | }, 61 | quoteBorder: { 62 | value: "{colors.slate.6}", 63 | }, 64 | caption: { 65 | value: "{colors.slate.11}", 66 | }, 67 | kbd: { 68 | value: "{colors.slate.11}", 69 | }, 70 | kbdShadow: { 71 | // Expects an RGB value 72 | value: "0 0 0", 73 | }, 74 | code: { 75 | value: "{colors.indigo.11}", 76 | }, 77 | preCode: { 78 | value: "{colors.slate.12}", 79 | }, 80 | preBg: { 81 | value: { base: "{colors.gray.50}", _osDark: "{colors.indigo.2}" }, 82 | }, 83 | thBorder: { 84 | value: "{colors.slate.6}", 85 | }, 86 | tdBorder: { 87 | value: "{colors.slate.6}", 88 | }, 89 | }, 90 | bodyBg: { 91 | value: { 92 | base: "white", 93 | _osDark: "{colors.indigo.1}", 94 | }, 95 | }, 96 | }, 97 | // colors: { 98 | // text: { 99 | // value: { 100 | // DEFAULT: "{colors.neutral.800}", 101 | // _osDark: "{colors.neutral.50}", 102 | // }, 103 | // }, 104 | // }, 105 | }, 106 | }, 107 | }, 108 | 109 | utilities: { 110 | extend: { 111 | size: { 112 | transform(value) { 113 | return { height: value, width: value }; 114 | }, 115 | group: "Width", 116 | values: "sizes", 117 | }, 118 | }, 119 | }, 120 | 121 | globalCss: { 122 | extend: { 123 | html: { 124 | fontFamily: "body", 125 | color: "prose.body", 126 | bg: "bodyBg", 127 | _motionSafe: { 128 | scrollBehavior: "smooth", 129 | }, 130 | }, 131 | code: { fontFamily: "mono" }, 132 | 'code[data-theme*=" "], code[data-theme*=" "] span': { 133 | color: "var(--shiki-light)", 134 | backgroundColor: "var(--shiki-light-bg)", 135 | _osDark: { 136 | color: "var(--shiki-dark)", 137 | backgroundColor: "var(--shiki-dark-bg)", 138 | }, 139 | }, 140 | }, 141 | }, 142 | 143 | // globalCss: { 144 | // extend: { 145 | // ".prose": { 146 | // "& > * + *": { 147 | // mt: "4", 148 | // }, 149 | // li: { mb: "2" }, 150 | // h1: { textStyle: "5xl", mt: "6" }, 151 | // h2: { textStyle: "4xl", mt: "6" }, 152 | // h3: { textStyle: "3xl", mt: "6" }, 153 | // h4: { textStyle: "2xl", mt: "6" }, 154 | // "ul li": { listStyleType: "disc" }, 155 | // "ol li": { listStyleType: "" }, 156 | // "ol, ul": { ml: "6" }, 157 | // }, 158 | // }, 159 | // }, 160 | 161 | // The output directory for your css system 162 | outdir: "styled-system", 163 | }); 164 | -------------------------------------------------------------------------------- /react-aria-carousel/src/utils/useMouseDrag.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2020 A Beautiful Site, LLC 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | * 6 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | * 8 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | */ 10 | 11 | import { MouseEventHandler, useCallback, useEffect, useRef } from "react"; 12 | 13 | export function useMouseDrag(host?: HTMLElement | null) { 14 | const dragging = useRef(false); 15 | 16 | const handleDragging = useCallback( 17 | (event: PointerEvent) => { 18 | if (!host) return; 19 | 20 | if (!dragging.current) { 21 | host.style.setProperty("scroll-snap-type", "none"); 22 | dragging.current = true; 23 | } 24 | 25 | host.scrollBy({ 26 | left: -event.movementX, 27 | top: -event.movementY, 28 | behavior: "instant", 29 | }); 30 | }, 31 | [host], 32 | ); 33 | 34 | const handleDragEnd = useCallback(() => { 35 | if (!host) return; 36 | 37 | document.removeEventListener("pointermove", handleDragging, { 38 | capture: true, 39 | }); 40 | 41 | // get the current scroll position 42 | const startLeft = host.scrollLeft; 43 | const startTop = host.scrollTop; 44 | 45 | // remove the scroll-snap-type property so that the browser will snap the slide to the correct position 46 | host.style.removeProperty("scroll-snap-type"); 47 | 48 | // fix(safari): forcing a style recalculation doesn't seem to immediately update the scroll 49 | // position in Safari. Setting "overflow" to "hidden" should force this behavior. 50 | host.style.setProperty("overflow", "hidden"); 51 | 52 | // get the final scroll position to the slide snapped by the browser 53 | const finalLeft = host.scrollLeft; 54 | const finalTop = host.scrollTop; 55 | 56 | // restore the scroll position to the original one, so that it can be smoothly animated if needed 57 | host.style.removeProperty("overflow"); 58 | host.style.setProperty("scroll-snap-type", "none"); 59 | host.scrollTo({ left: startLeft, top: startTop, behavior: "instant" }); 60 | 61 | requestAnimationFrame(async () => { 62 | if (startLeft !== finalLeft || startTop !== finalTop) { 63 | host.scrollTo({ 64 | left: finalLeft, 65 | top: finalTop, 66 | behavior: "smooth", 67 | }); 68 | await waitForEvent(host, "scrollend"); 69 | } 70 | 71 | host.style.removeProperty("scroll-snap-type"); 72 | }); 73 | 74 | dragging.current = false; 75 | }, [handleDragging, host]); 76 | 77 | const handleDragStart: MouseEventHandler = useCallback( 78 | (event) => { 79 | if (!host) return; 80 | // Primary click (usually left-click) 81 | let canDrag = event.button === 0; 82 | if (canDrag) { 83 | event.preventDefault(); 84 | 85 | document.addEventListener("pointermove", handleDragging, { 86 | capture: true, 87 | passive: true, 88 | }); 89 | document.addEventListener("pointerup", handleDragEnd, { 90 | capture: true, 91 | once: true, 92 | }); 93 | } 94 | }, 95 | [handleDragEnd, handleDragging, host], 96 | ); 97 | 98 | useEffect(() => { 99 | return () => { 100 | document.removeEventListener("pointermove", handleDragging, { 101 | capture: true, 102 | }); 103 | document.removeEventListener("pointerup", handleDragEnd, { 104 | capture: true, 105 | }); 106 | }; 107 | }, [handleDragEnd, handleDragging]); 108 | 109 | return { 110 | isDraggingRef: dragging, 111 | scrollerProps: { 112 | onMouseDown: handleDragStart, 113 | }, 114 | }; 115 | } 116 | 117 | export function waitForEvent(el: HTMLElement, eventName: string) { 118 | return new Promise((resolve) => { 119 | function done(event: Event) { 120 | if (event.target === el) { 121 | el.removeEventListener(eventName, done); 122 | resolve(); 123 | } 124 | } 125 | 126 | el.addEventListener(eventName, done); 127 | }); 128 | } 129 | -------------------------------------------------------------------------------- /site/app/prop-data.ts: -------------------------------------------------------------------------------- 1 | export const useCarousel = [ 2 | { 3 | name: `autoplay`, 4 | description: 5 | "If true, the carousel will scroll automatically when the user is not interacting with", 6 | defaultValue: `false`, 7 | type: "boolean", 8 | }, 9 | { 10 | name: `autoplayInterval`, 11 | description: 12 | "Specifies the amount of time, in milliseconds, between each automatic scroll. ", 13 | defaultValue: `5000`, 14 | type: "number", 15 | }, 16 | { 17 | name: `mouseDragging`, 18 | description: 19 | "Controls whether the user can scroll by clicking and dragging with their mouse. ", 20 | defaultValue: `false`, 21 | type: "boolean", 22 | }, 23 | { 24 | name: `scrollPadding`, 25 | description: 26 | "The amount of padding to apply to the scroll area, allowing adjacent items to become partially visible. ", 27 | defaultValue: "-", 28 | type: "string", 29 | }, 30 | { 31 | name: `spaceBetweenItems`, 32 | description: 33 | "The gap between items. ", 34 | defaultValue: `'0px'`, 35 | type: "string", 36 | }, 37 | { 38 | name: `itemsPerPage`, 39 | description: 40 | 'Number of items visible in a page. Can be an integer to show each item with equal dimensions, or a floating point number to "peek" subsequent items. ', 41 | defaultValue: `1`, 42 | type: "number", 43 | }, 44 | { 45 | name: `loop`, 46 | description: 47 | "Controls the pagination behavior at the beginning and end. ", 48 | defaultValue: `false`, 49 | type: "'infinite' | 'native' | false", 50 | }, 51 | { 52 | name: `orientation`, 53 | description: "The carousel scroll direction", 54 | defaultValue: `'horizontal'`, 55 | type: "'horizontal' | 'vertical'", 56 | }, 57 | { 58 | name: `scollBy`, 59 | description: 60 | "Controls whether scrolling snaps and pagination progresses by item or page.", 61 | defaultValue: `'page'`, 62 | type: "'page' | 'item'", 63 | }, 64 | { 65 | name: `initialPages`, 66 | description: 67 | "Define the organization of pages on first render. Useful to render navigation during SSR.", 68 | defaultValue: `[]`, 69 | type: "number[][]", 70 | }, 71 | { 72 | name: "onActivePageIndexChange", 73 | description: "Handler called when the activePageIndex changes.", 74 | defaultValue: "-", 75 | type: "(args: {index: number}) => void", 76 | }, 77 | ]; 78 | 79 | export const useCarouselItem = [ 80 | { 81 | name: "item", 82 | description: "An item in the collection of carousel items.", 83 | defaultValue: "-", 84 | type: "Item", 85 | }, 86 | ]; 87 | 88 | export const useCarouselTab = [ 89 | { 90 | name: "index", 91 | type: "number", 92 | description: "An index of a page in the carousel.", 93 | defaultValue: "-", 94 | }, 95 | { 96 | name: "isSelected", 97 | type: "boolean", 98 | description: 99 | "Whether the page is the active, visible page of the carousel.", 100 | defaultValue: "-", 101 | }, 102 | ]; 103 | 104 | export const Carousel = [ 105 | ...useCarousel, 106 | { 107 | name: "children", 108 | type: "ReactNode", 109 | defaultValue: "-", 110 | description: "The elements of the carousel.", 111 | }, 112 | ]; 113 | 114 | export const CarouselButton = [ 115 | { 116 | name: "dir", 117 | type: "'next' | 'prev'", 118 | defaultValue: "-", 119 | description: "Direction that the carousel should scroll when clicked.", 120 | }, 121 | ]; 122 | 123 | export const CarouselScroller = [ 124 | { 125 | name: "items", 126 | type: "Array", 127 | defaultValue: "-", 128 | description: "The data with which each item should be derived.", 129 | }, 130 | { 131 | name: "children", 132 | type: "ReactElement | ReactElement[] | ((item: T, index: number) => ReactElement)", 133 | defaultValue: "-", 134 | description: "The collection of carousel items.", 135 | }, 136 | ]; 137 | 138 | export const CarouselTabs = [ 139 | { 140 | name: "children", 141 | type: `(props: { 142 | isSelected: boolean; 143 | index: number 144 | }) => ReactNode`, 145 | defaultValue: "-", 146 | description: "Function that returns a CarouselTab", 147 | }, 148 | ]; 149 | 150 | export const CarouselItem = [ 151 | { 152 | name: "index", 153 | type: "number", 154 | defaultValue: "-", 155 | description: "The placement of the item in the carousel.", 156 | }, 157 | ]; 158 | 159 | export const CarouselAutoplayControl = [ 160 | { 161 | name: "children", 162 | type: "ReactNode | ((props: {autoplayUserPreference: boolean}) => ReactNode)", 163 | defaultValue: "-", 164 | description: "The content of the button.", 165 | }, 166 | ]; 167 | 168 | const props = { 169 | CarouselAutoplayControl, 170 | useCarousel, 171 | useCarouselItem, 172 | useCarouselTab, 173 | Carousel, 174 | CarouselButton, 175 | CarouselScroller, 176 | CarouselTabs, 177 | CarouselItem, 178 | }; 179 | 180 | export default props; 181 | -------------------------------------------------------------------------------- /react-aria-carousel/tests/Component.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import { describe, expect, test, vi } from "vitest"; 3 | 4 | import { 5 | Carousel, 6 | CarouselAutoplayControl, 7 | CarouselButton, 8 | CarouselItem, 9 | CarouselScroller, 10 | CarouselTab, 11 | CarouselTabs, 12 | } from "../src"; 13 | import { ComposedCarousel } from "./ComposedCarousel"; 14 | 15 | describe("a11y", () => { 16 | test("Root container attributes", () => { 17 | render(); 18 | expect(screen.getByRole("region")).toHaveAttribute( 19 | "aria-roledescription", 20 | "carousel", 21 | ); 22 | }); 23 | 24 | test("Carousel scroller element attributes", () => { 25 | render(); 26 | const scroller = screen.getByTestId("scroller"); 27 | expect(scroller).toHaveAttribute("aria-label", "Items Scroller"); 28 | expect(scroller).toHaveAttribute("tabIndex", "0"); 29 | expect(scroller).toHaveAttribute("aria-atomic", "true"); 30 | expect(scroller).toHaveAttribute("aria-live", "off"); 31 | expect(scroller).toHaveAttribute("aria-busy", "false"); 32 | expect(scroller).toHaveAttribute("role", "group"); 33 | }); 34 | 35 | test("Carousel item element attributes", () => { 36 | render(); 37 | const item = screen.getByTestId("item-0"); 38 | expect(item).toHaveAttribute("aria-label", "1 of 4"); 39 | expect(item).toHaveAttribute("role", "group"); 40 | expect(item).toHaveAttribute("aria-roledescription", "carousel item"); 41 | expect(item).not.toHaveAttribute("aria-hidden"); 42 | 43 | expect(screen.getByTestId("item-1")).toHaveAttribute("aria-hidden", "true"); 44 | expect(screen.getByTestId("item-1")).toHaveAttribute("inert", "true"); 45 | }); 46 | 47 | test("Carousel tablist element attributes", () => { 48 | render(); 49 | const item = screen.getByRole("tablist"); 50 | expect(item).toHaveAttribute( 51 | "aria-controls", 52 | screen.getByTestId("scroller").getAttribute("id"), 53 | ); 54 | expect(item).toHaveAttribute("aria-orientation", "horizontal"); 55 | expect(item).toHaveAttribute("aria-label", "Carousel navigation"); 56 | }); 57 | 58 | test("Carousel tab element attributes", () => { 59 | render(); 60 | const item = screen.getAllByRole("tab")[0]; 61 | expect(item).toHaveAttribute("aria-label", "Go to item 1 of 4"); 62 | expect(item).toHaveAttribute("aria-posinset", "1"); 63 | expect(item).toHaveAttribute("aria-setsize", "4"); 64 | expect(item).toHaveAttribute("aria-selected", "true"); 65 | expect(item).toHaveAttribute("tabIndex", "0"); 66 | 67 | const secondItem = screen.getAllByRole("tab")[1]; 68 | expect(secondItem).toHaveAttribute("aria-label", "Go to item 2 of 4"); 69 | expect(secondItem).toHaveAttribute("aria-posinset", "2"); 70 | expect(secondItem).toHaveAttribute("aria-setsize", "4"); 71 | expect(secondItem).toHaveAttribute("aria-selected", "false"); 72 | expect(secondItem).toHaveAttribute("tabIndex", "-1"); 73 | }); 74 | 75 | test("Carousel Autoplay element attributes", () => { 76 | render(); 77 | const btn = screen.getByTestId("autoplay"); 78 | expect(btn).toHaveAttribute("inert", "true"); 79 | expect(btn).toHaveAttribute("aria-label", "Enable autoplay"); 80 | expect(btn).toHaveAttribute( 81 | "aria-controls", 82 | screen.getByTestId("scroller").getAttribute("id"), 83 | ); 84 | }); 85 | 86 | test("Carousel next btn element attributes", () => { 87 | render(); 88 | const btn = screen.getByLabelText("Next page"); 89 | expect(btn).toHaveAttribute( 90 | "aria-controls", 91 | screen.getByTestId("scroller").getAttribute("id"), 92 | ); 93 | expect(btn).toHaveAttribute("data-next-button"); 94 | expect(btn).not.toHaveAttribute("aria-disabled"); 95 | }); 96 | 97 | test("Carousel next btn element attributes", () => { 98 | render(); 99 | const btn = screen.getByLabelText("Previous page"); 100 | expect(btn).toHaveAttribute( 101 | "aria-controls", 102 | screen.getByTestId("scroller").getAttribute("id"), 103 | ); 104 | expect(btn).toHaveAttribute("data-prev-button"); 105 | expect(btn).toHaveAttribute("aria-disabled"); 106 | }); 107 | }); 108 | 109 | test("ref access", () => { 110 | const rootRef = vi.fn(); 111 | const scrollerRef = vi.fn(); 112 | const prevButtonRef = vi.fn(); 113 | const nextButtonRef = vi.fn(); 114 | const itemRef = vi.fn(); 115 | const tablistRef = vi.fn(); 116 | const tabRef = vi.fn(); 117 | const autoplayControlRef = vi.fn(); 118 | render( 119 | 120 | 121 | Toggle autoplay 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | {({ index }) => } 130 | 131 | , 132 | ); 133 | 134 | expect(rootRef.mock.calls.at(-1)[0] instanceof HTMLElement).toBe(true); 135 | expect(scrollerRef.mock.calls.at(-1)[0] instanceof HTMLElement).toBe(true); 136 | expect(prevButtonRef.mock.calls.at(-1)[0] instanceof HTMLElement).toBe(true); 137 | expect(nextButtonRef.mock.calls.at(-1)[0] instanceof HTMLElement).toBe(true); 138 | expect(itemRef.mock.calls.at(-1)[0] instanceof HTMLElement).toBe(true); 139 | expect(tablistRef.mock.calls.at(-1)[0] instanceof HTMLElement).toBe(true); 140 | expect(tabRef.mock.calls.at(-1)[0] instanceof HTMLElement).toBe(true); 141 | expect(autoplayControlRef.mock.calls.at(-1)[0] instanceof HTMLElement).toBe( 142 | true, 143 | ); 144 | }); 145 | -------------------------------------------------------------------------------- /site/app/HeroCarousel.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import "smoothscroll-polyfill"; 4 | 5 | import { useEffect } from "react"; 6 | import { StockPhoto } from "@/components/Image"; 7 | import { flex, grid } from "@/styled-system/patterns"; 8 | import clsx from "clsx"; 9 | import { 10 | Carousel, 11 | CarouselButton, 12 | CarouselItem, 13 | CarouselScroller, 14 | CarouselTab, 15 | CarouselTabs, 16 | } from "react-aria-carousel"; 17 | import { FaCaretLeft, FaCaretRight } from "react-icons/fa6"; 18 | 19 | import { scrollEndPolyfill } from "./scrollend-polyfill"; 20 | import { css } from "@/styled-system/css"; 21 | 22 | const StyledCarouselButton = ({ dir }: { dir: "next" | "prev" }) => { 23 | return ( 24 | 56 | {dir === "prev" ? ( 57 | 58 | ) : ( 59 | 60 | )} 61 | 62 | ); 63 | }; 64 | 65 | export const HeroCarousel = () => { 66 | useEffect(() => { 67 | scrollEndPolyfill(); 68 | }, []); 69 | 70 | return ( 71 | 108 | 109 | 110 | 119 | 120 | Browser-native scroll snapping smooth scrolling 121 | 122 | 123 | Top-tier accessibility 124 | 125 | 126 | Bring your own styles 127 | 128 | 129 | Built with the latest web tech 130 | 131 | 132 | Packed with features! 133 | 134 | 135 | 144 | {({ index, isSelected }) => ( 145 | 158 | )} 159 | 160 | 161 | ); 162 | }; 163 | 164 | export function Slide({ 165 | emoji, 166 | children, 167 | index, 168 | ...props 169 | }: { 170 | emoji: string; 171 | children: string; 172 | index: number; 173 | }) { 174 | return ( 175 | 184 |
204 |
221 |
222 | ); 223 | } 224 | -------------------------------------------------------------------------------- /react-aria-carousel/stories/Test.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/react"; 2 | import { expect, fn, userEvent, waitFor, within } from "@storybook/test"; 3 | 4 | import { ComposedCarousel, Item } from "./ComposedCarousel"; 5 | 6 | const meta: Meta = { 7 | component: ComposedCarousel, 8 | tags: [], 9 | args: { 10 | children: ( 11 | <> 12 | 13 | 14 | 15 | 16 | 17 | ), 18 | }, 19 | }; 20 | 21 | export default meta; 22 | 23 | type Story = StoryObj; 24 | 25 | export const Basic: Story = { 26 | args: { 27 | onActivePageIndexChange: fn(), 28 | }, 29 | tags: [], 30 | play: async ({ canvasElement, args }) => { 31 | const canvas = within(canvasElement); 32 | await waitFor(() => { 33 | // Test hidden attributes 34 | expect(canvas.getByLabelText("1 of 4")).not.toHaveAttribute( 35 | "aria-hidden", 36 | ); 37 | expect(canvas.getByLabelText("2 of 4")).toHaveAttribute( 38 | "aria-hidden", 39 | "true", 40 | ); 41 | expect(canvas.getByLabelText("3 of 4")).toHaveAttribute( 42 | "aria-hidden", 43 | "true", 44 | ); 45 | expect(canvas.getByLabelText("4 of 4")).toHaveAttribute( 46 | "aria-hidden", 47 | "true", 48 | ); 49 | }); 50 | 51 | // After scrolling to the next item, the second item is visible 52 | // others are not 53 | canvas.getByLabelText("2 of 4").scrollIntoView(); 54 | await waitFor(() => { 55 | expect(args.onActivePageIndexChange).toHaveBeenCalledWith({ index: 1 }); 56 | expect(canvas.getByLabelText("1 of 4")).toHaveAttribute( 57 | "aria-hidden", 58 | "true", 59 | ); 60 | expect(canvas.getByLabelText("2 of 4")).not.toHaveAttribute( 61 | "aria-hidden", 62 | ); 63 | expect(canvas.getByLabelText("3 of 4")).toHaveAttribute( 64 | "aria-hidden", 65 | "true", 66 | ); 67 | expect(canvas.getByLabelText("4 of 4")).toHaveAttribute( 68 | "aria-hidden", 69 | "true", 70 | ); 71 | }); 72 | 73 | expect(args.onActivePageIndexChange).toHaveBeenCalledTimes(1); 74 | }, 75 | }; 76 | 77 | export const InfiniteLoop: Story = { 78 | args: { loop: "infinite", onActivePageIndexChange: fn() }, 79 | play: async ({ canvasElement, args }) => { 80 | const canvas = within(canvasElement); 81 | await waitFor(() => { 82 | const prev = canvas.getByLabelText("Previous page"); 83 | const next = canvas.getByLabelText("Next page"); 84 | expect(prev).toBeEnabled(); 85 | expect(next).toBeEnabled(); 86 | 87 | userEvent.click(prev); 88 | }); 89 | await waitFor( 90 | async () => { 91 | const next = canvas.getByLabelText("Next page"); 92 | // Ignore clone at the beginning 93 | await expect( 94 | canvas.getAllByLabelText("4 of 4").at(-1), 95 | ).not.toHaveAttribute("aria-hidden"); 96 | await expect(next).toBeEnabled(); 97 | await expect(args.onActivePageIndexChange).toHaveBeenLastCalledWith({ 98 | index: 3, 99 | }); 100 | }, 101 | // Timeout needed because of how the "scrollend" handler called 102 | // Instead of using the scrollend event, we're using the scorll event 103 | // with a timer to check if scrolling has stopped 104 | { timeout: 2000 }, 105 | ); 106 | expect(args.onActivePageIndexChange).toHaveBeenCalledTimes(1); 107 | }, 108 | }; 109 | 110 | export const NativeLoop: Story = { 111 | args: { loop: "native", onActivePageIndexChange: fn() }, 112 | play: async ({ canvasElement, args }) => { 113 | const canvas = within(canvasElement); 114 | await waitFor(async () => { 115 | const prev = canvas.getByLabelText("Previous page"); 116 | const next = canvas.getByLabelText("Next page"); 117 | expect(prev).toBeEnabled(); 118 | expect(next).toBeEnabled(); 119 | 120 | userEvent.click(prev); 121 | }); 122 | await waitFor(() => { 123 | const next = canvas.getByLabelText("Next page"); 124 | expect(args.onActivePageIndexChange).toHaveBeenCalledWith({ 125 | index: 3, 126 | }); 127 | // Ignore clone at the beginning 128 | expect(canvas.getAllByLabelText("4 of 4").at(-1)).not.toHaveAttribute( 129 | "aria-hidden", 130 | ); 131 | expect(next).toBeEnabled(); 132 | }); 133 | expect(args.onActivePageIndexChange).toHaveBeenCalledTimes(1); 134 | }, 135 | }; 136 | 137 | export const Autoplay: Story = { 138 | args: { 139 | autoplay: true, 140 | autoplayInterval: 500, 141 | onActivePageIndexChange: fn(), 142 | }, 143 | play: async ({ canvasElement, args }) => { 144 | const canvas = within(canvasElement); 145 | const btn = canvas.getByLabelText("Disable autoplay"); 146 | await waitFor(() => { 147 | expect(canvas.getByLabelText("1 of 4")).not.toHaveAttribute( 148 | "aria-hidden", 149 | ); 150 | }); 151 | 152 | await wait(args.autoplayInterval! + 100); 153 | await waitFor(() => { 154 | expect(args.onActivePageIndexChange).toHaveBeenLastCalledWith({ 155 | index: 1, 156 | }); 157 | expect(canvas.getByLabelText("1 of 4")).toHaveAttribute("aria-hidden"); 158 | expect(canvas.getByLabelText("2 of 4")).not.toHaveAttribute( 159 | "aria-hidden", 160 | ); 161 | userEvent.click(btn); 162 | }); 163 | 164 | await wait((args.autoplayInterval! + 100) * 2); 165 | await waitFor(async () => { 166 | expect(btn).toHaveAttribute("aria-label", "Enable autoplay"); 167 | await expect(canvas.getByLabelText("1 of 4")).toHaveAttribute( 168 | "aria-hidden", 169 | ); 170 | await expect(canvas.getByLabelText("2 of 4")).not.toHaveAttribute( 171 | "aria-hidden", 172 | ); 173 | await expect(canvas.getByLabelText("3 of 4")).toHaveAttribute( 174 | "aria-hidden", 175 | ); 176 | }); 177 | 178 | expect(args.onActivePageIndexChange).toHaveBeenCalledTimes(1); 179 | }, 180 | }; 181 | 182 | // For testing bug raised in https://github.com/roginfarrer/react-aria-carousel/issues/2 183 | export const UnevenItems: Story = { 184 | args: { 185 | itemsPerPage: 3, 186 | children: ( 187 | <> 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | ), 198 | }, 199 | play: async ({ canvasElement }) => { 200 | const canvas = within(canvasElement); 201 | 202 | await waitFor(async () => { 203 | canvas.getByLabelText("8 of 8").scrollIntoView({ block: "nearest" }); 204 | expect(canvas.getByLabelText("8 of 8")).not.toHaveAttribute( 205 | "aria-hidden", 206 | ); 207 | expect(canvas.getByLabelText("Go to page 2 of 3")).toHaveAttribute( 208 | "aria-selected", 209 | "false", 210 | ); 211 | expect(canvas.getByLabelText("Go to page 3 of 3")).toHaveAttribute( 212 | "aria-selected", 213 | "true", 214 | ); 215 | }); 216 | }, 217 | }; 218 | 219 | const wait = (time: number) => { 220 | return new Promise((resolve) => setTimeout(resolve, time)); 221 | }; 222 | -------------------------------------------------------------------------------- /react-aria-carousel/src/useCarousel.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Dispatch, 5 | KeyboardEvent, 6 | KeyboardEventHandler, 7 | SetStateAction, 8 | useCallback, 9 | useId, 10 | useState, 11 | } from "react"; 12 | 13 | import { 14 | CarouselState, 15 | CarouselStateProps, 16 | useCarouselState, 17 | } from "./useCarouselState"; 18 | import { 19 | Attributes, 20 | noop, 21 | useAriaBusyScroll, 22 | useMouseDrag, 23 | usePrefersReducedMotion, 24 | } from "./utils"; 25 | import { useAutoplay } from "./utils/useAutoplay"; 26 | 27 | /** 28 | * Options for useCarousel 29 | */ 30 | export interface CarouselOptions extends CarouselStateProps { 31 | /** 32 | * The gap between items. 33 | * @defaultValue '0px' 34 | */ 35 | spaceBetweenItems?: string; 36 | /** 37 | * The amount of padding to apply to the scroll area, allowing adjacent items 38 | * to become partially visible. 39 | */ 40 | scrollPadding?: string; 41 | /** 42 | * Controls whether the user can scroll by clicking and dragging with their mouse. 43 | * @default false 44 | */ 45 | mouseDragging?: boolean; 46 | /** 47 | * If true, the carousel will scroll automatically when the user is not interacting with it. 48 | * @default false 49 | */ 50 | autoplay?: boolean; 51 | /** 52 | * Specifies the amount of time, in milliseconds, between each automatic scroll. 53 | * @default 5000 54 | */ 55 | autoplayInterval?: number; 56 | } 57 | 58 | /** 59 | * API returned by useCarousel 60 | */ 61 | export interface CarouselAria extends CarouselState { 62 | autoplayUserPreference: boolean; 63 | /** Props for the tab list element */ 64 | readonly tablistProps: Attributes<"div">; 65 | /** Props for the root element */ 66 | readonly rootProps: Attributes<"div">; 67 | /** Props for the previous button element */ 68 | readonly prevButtonProps: Attributes<"button">; 69 | /** Props for the next button element */ 70 | readonly nextButtonProps: Attributes<"button">; 71 | /** Props for the scroller element */ 72 | readonly scrollerProps: Attributes<"div">; 73 | /** Props for the autoplay toggle element */ 74 | readonly autoplayControlProps: Attributes<"button">; 75 | } 76 | 77 | export function useCarousel({ 78 | itemsPerPage = 1, 79 | loop = false, 80 | orientation = "horizontal", 81 | spaceBetweenItems = "0px", 82 | mouseDragging = false, 83 | autoplay: propAutoplay = false, 84 | autoplayInterval = 5000, 85 | scrollPadding, 86 | onActivePageIndexChange, 87 | }: CarouselOptions = {}): [ 88 | Dispatch>, 89 | CarouselAria, 90 | ] { 91 | const [host, setHost] = useState(null); 92 | const { 93 | isDraggingRef, 94 | scrollerProps: { onMouseDown }, 95 | } = useMouseDrag(host); 96 | const state = useCarouselState( 97 | { 98 | itemsPerPage, 99 | loop, 100 | mouseDragging, 101 | isDraggingRef, 102 | onActivePageIndexChange, 103 | }, 104 | host, 105 | ); 106 | const { pages, activePageIndex, next, prev, scrollToPage } = state; 107 | const scrollerId = useId(); 108 | const prefersReducedMotion = usePrefersReducedMotion(); 109 | const { 110 | rootProps: autoplayRootProps, 111 | autoplayUserPreference, 112 | setAutoplayUserPreference, 113 | } = useAutoplay({ 114 | enabled: !prefersReducedMotion && propAutoplay, 115 | interval: autoplayInterval, 116 | next, 117 | }); 118 | 119 | const handleKeyDown = useCallback( 120 | (e: KeyboardEvent): number | undefined => { 121 | if ( 122 | ![ 123 | "ArrowLeft", 124 | "ArrowRight", 125 | "ArrowUp", 126 | "ArrowDown", 127 | "Home", 128 | "End", 129 | ].includes(e.key) 130 | ) 131 | return; 132 | 133 | e.preventDefault(); 134 | let nextPageIndex: number | undefined; 135 | 136 | switch (e.key) { 137 | case "ArrowUp": { 138 | if (orientation === "vertical") { 139 | nextPageIndex = prev(); 140 | } 141 | break; 142 | } 143 | case "ArrowRight": { 144 | if (orientation === "horizontal") { 145 | nextPageIndex = next(); 146 | } 147 | break; 148 | } 149 | case "ArrowDown": { 150 | if (orientation === "vertical") { 151 | nextPageIndex = next(); 152 | } 153 | break; 154 | } 155 | case "ArrowLeft": { 156 | if (orientation === "horizontal") { 157 | nextPageIndex = prev(); 158 | } 159 | break; 160 | } 161 | case "Home": { 162 | scrollToPage(0); 163 | nextPageIndex = 0; 164 | break; 165 | } 166 | case "End": { 167 | scrollToPage(pages.length - 1); 168 | nextPageIndex = pages.length - 1; 169 | break; 170 | } 171 | } 172 | return nextPageIndex; 173 | }, 174 | [next, orientation, pages.length, prev, scrollToPage], 175 | ); 176 | 177 | const handleTablistKeydown: KeyboardEventHandler = useCallback( 178 | (e) => { 179 | const nextIndex = handleKeyDown(e); 180 | if (!nextIndex) return; 181 | 182 | const target = e.target as HTMLElement; 183 | if ( 184 | document.activeElement === target || 185 | document.activeElement?.contains(target) 186 | ) { 187 | const tab = document.querySelector( 188 | `[data-carousel-tab="${nextIndex}"]`, 189 | ) as HTMLElement | null; 190 | tab?.focus(); 191 | } 192 | }, 193 | [handleKeyDown], 194 | ); 195 | 196 | useAriaBusyScroll(host); 197 | 198 | return [ 199 | setHost, 200 | { 201 | ...state, 202 | autoplayUserPreference, 203 | rootProps: { 204 | ...autoplayRootProps, 205 | role: "region", 206 | "aria-roledescription": "carousel", 207 | }, 208 | tablistProps: { 209 | role: "tablist", 210 | "aria-controls": scrollerId, 211 | "aria-orientation": orientation, 212 | "aria-label": "Carousel navigation", 213 | onKeyDown: handleTablistKeydown, 214 | }, 215 | prevButtonProps: { 216 | "aria-label": "Previous page", 217 | "aria-controls": scrollerId, 218 | "data-prev-button": true, 219 | onClick: () => prev(), 220 | "aria-disabled": loop 221 | ? undefined 222 | : activePageIndex <= 0 223 | ? true 224 | : undefined, 225 | }, 226 | nextButtonProps: { 227 | "aria-label": "Next page", 228 | "aria-controls": scrollerId, 229 | "data-next-button": true, 230 | onClick: () => next(), 231 | "aria-disabled": loop 232 | ? undefined 233 | : activePageIndex >= pages.length - 1 234 | ? true 235 | : undefined, 236 | }, 237 | scrollerProps: { 238 | "data-carousel-scroller": true, 239 | "aria-label": "Items Scroller", 240 | "data-orientation": orientation, 241 | onMouseDown: mouseDragging ? onMouseDown : noop, 242 | onKeyDown: handleKeyDown, 243 | tabIndex: 0, 244 | "aria-atomic": true, 245 | "aria-live": propAutoplay && autoplayUserPreference ? "polite" : "off", 246 | "aria-busy": false, 247 | id: scrollerId, 248 | role: "group", 249 | style: { 250 | [`gridAuto${orientation === "horizontal" ? "Columns" : "Rows"}`]: `calc(100% / ${itemsPerPage} - ${spaceBetweenItems} * ${itemsPerPage - 1} / ${itemsPerPage})`, 251 | [`scrollPadding${orientation === "horizontal" ? "Inline" : "Block"}`]: 252 | scrollPadding, 253 | [`padding${orientation === "horizontal" ? "Inline" : "Block"}`]: 254 | scrollPadding, 255 | gap: spaceBetweenItems, 256 | }, 257 | }, 258 | autoplayControlProps: { 259 | inert: !propAutoplay ? "true" : undefined, 260 | "aria-label": autoplayUserPreference 261 | ? "Disable autoplay" 262 | : "Enable autoplay", 263 | "aria-controls": scrollerId, 264 | onClick() { 265 | setAutoplayUserPreference((prev) => !prev); 266 | }, 267 | }, 268 | }, 269 | ]; 270 | } 271 | -------------------------------------------------------------------------------- /site/app/intro.mdx: -------------------------------------------------------------------------------- 1 | import { PropTable } from "@/components/PropTable"; 2 | 3 | import propData from "./prop-data"; 4 | 5 | import Autoplay from "../examples/autoplay/index.tsx"; 6 | import Basic from "../examples/basic"; 7 | import Looping from "../examples/loop"; 8 | import MouseDragging from "../examples/mouse-dragging"; 9 | import MultipleItems from "../examples/multiple-items"; 10 | import Orientation from "../examples/orientation"; 11 | import ScrollHints from "../examples/scroll-hints"; 12 | import Translated from "../examples/translations"; 13 | import { css } from "../styled-system/css"; 14 | 15 |
16 | 17 | **React Aria Carousel** is a collection of React hooks and components that provide the behavior and accessibility implementation for building a carousel. Unlike many other carousels, this implementation uses the latest browser and DOM APIs for scrolling and snapping. 18 | 19 | ## Features 20 | 21 | - Native **browser scroll-snapping and smooth-scrolling** for performant transitions across pointer types. 22 | - **Comprehensive behavior and accessibility coverage** for all elements of a carousel, including pagination, infinite scrolling, autoplay, and more. 23 | - Support for showing **one or many items per page**, implemented with responsive-design. 24 | - Support for **vertical and horizontal** scrolling. 25 | - Support for **infinite scrolling**. 26 | - Support for **autoplay** with affordances for disabling it. 27 | - Support for **mouse dragging**. 28 | - Written in **TypeScript**. 29 | 30 | ## Installation 31 | 32 | ```sh 33 | npm install react-aria-carousel 34 | ``` 35 | 36 | ## Usage 37 | 38 | ### Anatomy 39 | 40 | {/* prettier-ignore */} 41 | ```tsx 42 | import { 43 | Carousel, 44 | CarouselButton, 45 | CarouselButton, 46 | CarouselItem, 47 | CarouselScroller, 48 | CarouselTab, 49 | CarouselTabs, 50 | } from "react-aria-carousel"; 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | {(page) => ( 61 | 62 | )} 63 | 64 | 65 | ``` 66 | 67 | Carousels have a root element denoting the carousel region, which contains all of the different parts of the carousel, including the scrolling container, next and previous buttons, and optionally, tabs and the autoplay toggle. 68 | 69 | Below is a simple example with minimal styles to show all of the different parts working together. 70 | 71 | 72 | 73 | ### Multiple Items 74 | 75 | The number of items present on a page can be changed with the `itemsPerPage` prop. You can change the spacing between items with the `spaceBetweenItems` prop. 76 | 77 | 78 | 79 | ### Looping 80 | 81 | By default, the carousel will not loop if one scrolls to either end. You can force the carousel to "wrap" in two different ways. 82 | 83 | If the `loop` prop is set to `"infinite"`, the carousel will give the appearance of seamlessly wrapping to the opposite end. 84 | 85 | If the `loop` props is set to `"native"`, the user still won't be able to scroll to the other end. However, the next and previous buttons and keyboard controls will scroll the user to the other end. ("Native" in this case means that the behavior of the scroll container aren't manipulated, it just provides the functionality to scroll the user to the other end.) 86 | 87 | 88 | 89 | ### Orientation 90 | 91 | By default, the carousel will scroll horizontally. This can be changed to scroll vertically with the `orientation` prop. 92 | 93 | 94 | 95 | ### Mouse Dragging 96 | 97 | The carousel uses [CSS scroll snap](https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-snap-type) to position slides at various snap positions. This allows users to scroll through the slides very naturally, especially on touch devices. Unfortunately, desktop users won’t be able to click and drag with a mouse, which can feel unnatural. The carousel provides this functionality with the `mouseDragging` prop. 98 | 99 | This example is best demonstrated using a mouse. Try clicking and dragging the slide to move it. 100 | 101 | 102 | 103 | ### Autoplay 104 | 105 | Use the `autoplay` prop to automatically progress the carousel, dictated by the `autoplayInterval` prop. This functionality works great with the `loop` prop enabled. For best usability and accessibility, autoplay will pause if the user starts interacting with the carousel, and will resume when the stop interacting with it. Autoplay will also be disabled if the user's operating system or browser settings has the "prefers reduced motion" option enabled. 106 | 107 | It's very important to include the `CarouselAutoplayControl` component when using this prop! Users must be able to disable this functionality. 108 | 109 | 110 | 111 | ### Scroll Hints 112 | 113 | The carousel has two different ways to hint to users that there are more items in the carousel. 114 | 115 | The `scrollPadding` prop simply applies [CSS scroll-padding](https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-padding) to the scroll container. This has the effect of hinting the presence of items on either side of the carousel. 116 | 117 | You can also manipulate the `itemsPerPage` prop to hint at more items. If you use a non-integer number, like `2.25`, the carousel will show a quarter of the following item in the list. This option has the benefit of keeping the edges of the carousel nicely aligned to its container (as opposed to `scrollPadding`). 118 | 119 | 120 | 121 | ## Accessibility & Labeling 122 | 123 | React Aria Carousel includes most required labels and HTML semantics by default, but requires a label (via `aria-label` or `aria-labelledby`) for the `Carousel` component for it to properly be described by screen readers. 124 | 125 | All other aria attributes and descriptions are provided in English. For non-English languages, they can be overridden. Here's an example of the component translated to Italian: 126 | 127 | 128 | 129 | Note: translations may be innaccurate, I used Google Translate for them :). 130 | 131 | ## Hooks 132 | 133 | If you need to customize things even further, such as accessing internal state or customizing DOM structure, you can drop down to the lower level Hook-based API. 134 | 135 | ```tsx 136 | import { 137 | useCarousel, 138 | useCarouselItem, 139 | useCarouselTab, 140 | } from "react-aria-carousel"; 141 | 142 | export function Carousel() { 143 | const [assignScrollerRef, carousel] = useCarousel({ 144 | spaceBetweenItems: "16px", 145 | itemsPerPage: 2, 146 | }); 147 | 148 | const { 149 | rootProps, 150 | prevButtonProps, 151 | nextButtonProps, 152 | scrollerProps, 153 | tabProps, 154 | pages, 155 | autoplayControlProps, 156 | } = carousel; 157 | 158 | return ( 159 |
160 | 161 |
162 | 163 | 164 |
165 |
166 | 167 |
168 |
169 | {pages.map((_, i) => ( 170 | 171 | ))} 172 |
173 |
174 | ); 175 | } 176 | 177 | function CarouselItem({ index, state }) { 178 | const { itemProps } = useCarouselItem({ index }, state); 179 | return
; 180 | } 181 | 182 | function CarouselTab({ index, state }) { 183 | const { tabProps } = useCarouselTab({ index }, state); 184 | return
; 185 | } 186 | ``` 187 | 188 | ## API 189 | 190 | ### Carousel 191 | 192 | The component accepts any valid HTML attributes, in addition to the following: 193 | 194 | 195 | 196 | ### CarouselScroller 197 | 198 | The component accepts any valid HTML attributes, in addition to the following: 199 | 200 | 201 | 202 | ### CarouselItem 203 | 204 | The component accepts any valid HTML attributes, in addition to the following: 205 | 206 | 207 | 208 | ### CarouselButton 209 | 210 | The component accepts any valid HTML button attributes, in addition to the following: 211 | 212 | 213 | 214 | ### CarouselTabs 215 | 216 | The component accepts any valid HTML attributes, in addition to the following: 217 | 218 | 219 | 220 | ### CarouselTab 221 | 222 | `CarouselTab` does not have any unique props, and accepts any valid `HTMLButtonElement` prop. 223 | 224 | ### CarouselAutoplayControl 225 | 226 | The component accepts any valid HTML attributes, in addition to the following: 227 | 228 | 229 | 230 | ### useCarousel 231 | 232 | 233 | 234 | ### useCarouselItem 235 | 236 | 237 | 238 | ### useCarouselTab 239 | 240 | 241 | 242 | ## Browser Support 243 | 244 | A goal of this project is to use modern browser technology. While most APIs are widely supported, the [`scrollend`](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollend_event) notably is not supported in Safari (as of May 2024). A polyfill is not included to future-proof the project, but you will likely want to include one. 245 | 246 | ## Acknowledgements 247 | 248 | - [react-snap-carousel](https://github.com/richardscarrott/react-snap-carousel) is another DOM-first, headless carousel for React that was a big insipiration for this project. It's less opinionated about the layout of the carousel (i.e., does not require CSS grid). 249 | - [Shoelace carousel](https://shoelace.style/components/carousel) is a Web Component implementation for a DOM-first carousel, that can also be used in React. It's a bit more opinionated about the HTML layout and presentation of the carousel. 250 | - Blog posts and resources used to inform the accessibility implementation: 251 | - ["A Step-By-Step Guide To Building Accessible Carousels"](https://www.smashingmagazine.com/2023/02/guide-building-accessible-carousels/) by Sonja Weckenmann at Smashing Magazine 252 | - [W3C Carousels](https://www.w3.org/WAI/tutorials/carousels/) 253 | - [W3C ARIA Carousel Authoring Practices](https://www.w3.org/WAI/ARIA/apg/patterns/carousel/) 254 | - [Thinking on ways to solve carousels](https://www.youtube.com/watch?v=CXJv6zM003M) by Adam Argyle (video) 255 | -------------------------------------------------------------------------------- /react-aria-carousel/src/useCarouselState.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useMemo, useState } from "react"; 2 | 3 | import { clamp, useCallbackRef, usePrefersReducedMotion } from "./utils"; 4 | 5 | /** 6 | * Options for useCarouselState 7 | */ 8 | export interface CarouselStateProps { 9 | /** 10 | * Number of items visible on a page. Can be an integer to 11 | * show each item with equal dimensions, or a floating point 12 | * number to "peek" subsequent items. 13 | * @default 1 14 | */ 15 | itemsPerPage?: number; 16 | /** 17 | * Controls the pagination behavior at the beginning and end. 18 | * "infinite" - will seamlessly loop to the other end of the carousel. 19 | * "native" - will scroll to the other end of the carousel. 20 | * false - will not advance beyond the first and last items. 21 | * @default false 22 | */ 23 | loop?: "infinite" | "native" | false; 24 | /** 25 | * The carousel scroll direction. 26 | * @default 'horizontal' 27 | */ 28 | orientation?: "vertical" | "horizontal"; 29 | /** 30 | * Controls whether scrolling snaps and pagination progresses by item or page. 31 | * @default 'page' 32 | */ 33 | scrollBy?: "page" | "item"; 34 | /** 35 | * Define the organization of pages on first render. 36 | * Useful to render navigation during SSR. 37 | * @default [] 38 | */ 39 | initialPages?: number[][]; 40 | /** Whether the carousel should scroll when the user drags with their mouse */ 41 | mouseDragging?: boolean; 42 | /** 43 | * Ref object that reflects whether the user is actively dragging 44 | * the carousel with their mouse 45 | */ 46 | isDraggingRef?: { current: boolean }; 47 | /** 48 | * Handler called when the activePageIndex changes 49 | */ 50 | onActivePageIndexChange?: ({ index }: { index: number }) => void; 51 | } 52 | 53 | /** 54 | * API returned by useCarouselState 55 | */ 56 | export interface CarouselState 57 | extends Required> { 58 | /** The index of the page in view. */ 59 | readonly activePageIndex: number; 60 | /** The indexes of all items organized into arrays. */ 61 | readonly pages: number[][]; 62 | /** Scrolls the carousel to the next page. */ 63 | next: () => number; 64 | /** Scrolls the carousel to the previous page. */ 65 | prev: () => number; 66 | /** Scrolls the carousel to the provided page index. */ 67 | scrollToPage: (index: number) => void; 68 | } 69 | 70 | export function useCarouselState( 71 | { 72 | itemsPerPage = 1, 73 | scrollBy = "page", 74 | loop = false, 75 | initialPages = [], 76 | isDraggingRef, 77 | mouseDragging, 78 | onActivePageIndexChange: propChangeHandler, 79 | }: CarouselStateProps, 80 | host: HTMLElement | null, 81 | ): CarouselState { 82 | const onActivePageIndexChange = useCallbackRef(propChangeHandler); 83 | const [activePageIndex, setActivePageIndex] = useState(0); 84 | const [pages, setPages] = useState(initialPages); 85 | const prefersReducedMotion = usePrefersReducedMotion(); 86 | 87 | const getItems = useCallback( 88 | ( 89 | { includeClones }: { includeClones?: boolean } = { includeClones: false }, 90 | ): HTMLElement[] => { 91 | if (!host) return []; 92 | let allChildren = Array.from(host.children) as HTMLElement[]; 93 | if (includeClones) return allChildren; 94 | return allChildren.filter((child) => !child.hasAttribute("data-clone")); 95 | }, 96 | [host], 97 | ); 98 | 99 | const scrollToItem = useCallback( 100 | (slide: HTMLElement, behavior: ScrollBehavior = "smooth"): void => { 101 | if (!host) return; 102 | const scrollContainerRect = host.getBoundingClientRect(); 103 | const nextSlideRect = slide.getBoundingClientRect(); 104 | 105 | const nextLeft = nextSlideRect.left - scrollContainerRect.left; 106 | const nextTop = nextSlideRect.top - scrollContainerRect.top; 107 | 108 | host.scrollTo({ 109 | left: nextLeft + host.scrollLeft, 110 | top: nextTop + host.scrollTop, 111 | behavior: prefersReducedMotion ? "instant" : behavior, 112 | }); 113 | }, 114 | [prefersReducedMotion, host], 115 | ); 116 | 117 | const scrollToPage = useCallback( 118 | (index: number, behavior?: ScrollBehavior): void => { 119 | const items = getItems(); 120 | const page = pages[index]; 121 | const itemIndex = page?.[0]; 122 | if (items[itemIndex]) { 123 | scrollToItem(items[itemIndex], behavior); 124 | } 125 | }, 126 | [getItems, pages, scrollToItem], 127 | ); 128 | 129 | const updateSnaps = useCallback((): void => { 130 | const actualItemsPerPage = Math.floor(itemsPerPage); 131 | getItems({ includeClones: true }).forEach((item, index) => { 132 | const shouldSnap = 133 | scrollBy === "item" || 134 | (index! + actualItemsPerPage) % actualItemsPerPage === 0; 135 | if (shouldSnap) { 136 | item.style.setProperty("scroll-snap-align", "start"); 137 | } else { 138 | item.style.removeProperty("scroll-snap-align"); 139 | } 140 | }); 141 | }, [getItems, itemsPerPage, scrollBy]); 142 | 143 | const calculatePages = useCallback((): void => { 144 | const items = getItems(); 145 | // We want to calculate the sets of pages based on the number 146 | // only 100% in view on a given page. If a number like 1.1 is provided, 147 | // the 10% we're peeking shouldn't count as an item on the page. 148 | const actualItemsPerPage = Math.floor(itemsPerPage); 149 | let newPages = items.reduce((acc, _, i) => { 150 | const currPage = acc.at(-1); 151 | if (currPage && currPage.length < actualItemsPerPage) { 152 | currPage.push(i); 153 | } else { 154 | acc.push([i]); 155 | } 156 | return acc; 157 | }, []); 158 | 159 | if (newPages.length >= 2) { 160 | let deficit = actualItemsPerPage - newPages.at(-1)!.length; 161 | if (deficit > 0) { 162 | const fill = [...newPages.at(-2)!].splice(actualItemsPerPage - deficit); 163 | newPages.at(-1)!.unshift(...fill); 164 | } 165 | } 166 | setPages(newPages); 167 | setActivePageIndex((prev) => { 168 | const index = clamp(0, prev, newPages.length - 1); 169 | if (index !== prev) { 170 | onActivePageIndexChange?.({ index }); 171 | } 172 | return index; 173 | }); 174 | }, [getItems, itemsPerPage, onActivePageIndexChange]); 175 | 176 | const scrollToPageIndex = useCallback( 177 | (index: number): number => { 178 | const items = getItems(); 179 | const itemsWithClones = getItems({ includeClones: true }); 180 | const pagesWithClones = [pages.at(-1), ...pages, pages[0]]; 181 | 182 | if (!items.length) return -1; 183 | 184 | let nextItem: HTMLElement, nextPageIndex: number, nextPage: number[]; 185 | 186 | // @TODO: This should be rewritten for clarity and brevity 187 | if (loop === "infinite") { 188 | // The index allowing to be inclusive of cloned pages 189 | let nextIndex = clamp(-1, index, pagesWithClones.length); 190 | 191 | if (nextIndex < 0) { 192 | // First item in the prepended cloned page 193 | nextItem = itemsWithClones[0]; 194 | nextPageIndex = pages.length - 1; 195 | nextPage = pages[nextPageIndex]; 196 | } else if (nextIndex >= pages.length) { 197 | // First item in the appended cloned page 198 | nextItem = itemsWithClones.at(-1 * itemsPerPage) as HTMLElement; 199 | nextPageIndex = 0; 200 | nextPage = pages[0]; 201 | } else { 202 | nextPageIndex = nextIndex; 203 | nextPage = pages[nextIndex]; 204 | nextItem = items[nextPage[0]]; 205 | } 206 | } else if (loop === "native") { 207 | nextPageIndex = 208 | index > pages.length - 1 ? 0 : index < 0 ? pages.length - 1 : index; 209 | nextPage = pages[nextPageIndex]; 210 | let itemIndex = nextPage[0]; 211 | nextItem = items[itemIndex]; 212 | } else { 213 | nextPageIndex = clamp(0, index, pages.length - 1); 214 | nextPage = pages[nextPageIndex]; 215 | let itemIndex = nextPage[0]; 216 | nextItem = items[itemIndex]; 217 | } 218 | 219 | scrollToItem(nextItem); 220 | return nextPageIndex; 221 | }, 222 | [getItems, itemsPerPage, loop, pages, scrollToItem], 223 | ); 224 | 225 | const next = useCallback((): number => { 226 | return scrollToPageIndex(activePageIndex + 1); 227 | }, [activePageIndex, scrollToPageIndex]); 228 | 229 | const prev = useCallback((): number => { 230 | return scrollToPageIndex(activePageIndex - 1); 231 | }, [activePageIndex, scrollToPageIndex]); 232 | 233 | useEffect(() => { 234 | if (!host || pages.length === 0) return; 235 | 236 | getItems({ includeClones: true }).forEach((item) => { 237 | if (item.hasAttribute("data-clone")) { 238 | item.remove(); 239 | } 240 | }); 241 | 242 | if (loop === "infinite") { 243 | const items = getItems(); 244 | const firstPage = pages[0]; 245 | // We're gonna modify this in a second with .reverse, so make sure not to mutate state 246 | const lastPage = [...pages.at(-1)!]; 247 | 248 | if (firstPage === lastPage) return; 249 | 250 | lastPage.reverse().forEach((slide) => { 251 | const clone = items[slide].cloneNode(true) as HTMLElement; 252 | clone.setAttribute("data-clone", "true"); 253 | clone.setAttribute("inert", "true"); 254 | clone.setAttribute("aria-hidden", "true"); 255 | host.prepend(clone); 256 | }); 257 | 258 | firstPage.forEach((slide) => { 259 | const clone = items[slide].cloneNode(true) as HTMLElement; 260 | clone.setAttribute("data-clone", "true"); 261 | clone.setAttribute("inert", "true"); 262 | clone.setAttribute("aria-hidden", "true"); 263 | host.append(clone); 264 | }); 265 | } 266 | 267 | updateSnaps(); 268 | scrollToPage(activePageIndex, "instant"); 269 | // Purposefully avoiding running this effect if the page index changes 270 | // Otherwise we're just running this all the time. 271 | // We just want to grab the latest activePageIndex when this runs otherwise 272 | // eslint-disable-next-line react-hooks/exhaustive-deps 273 | }, [ 274 | // activePageIndex, 275 | getItems, 276 | loop, 277 | pages, 278 | scrollToPage, 279 | host, 280 | updateSnaps, 281 | ]); 282 | 283 | useEffect(() => { 284 | if (!host) return; 285 | 286 | calculatePages(); 287 | updateSnaps(); 288 | 289 | const mutationObserver = new MutationObserver((mutations) => { 290 | const childrenChanged = mutations.some((mutation) => 291 | // @ts-expect-error This is fine 292 | [...mutation.addedNodes, ...mutation.removedNodes].some( 293 | (el: HTMLElement) => 294 | el.hasAttribute("data-carousel-item") && 295 | !el.hasAttribute("data-clone"), 296 | ), 297 | ); 298 | if (childrenChanged) { 299 | calculatePages(); 300 | updateSnaps(); 301 | } 302 | }); 303 | 304 | mutationObserver.observe(host, { childList: true, subtree: true }); 305 | return () => { 306 | mutationObserver.disconnect(); 307 | }; 308 | }, [getItems, host, calculatePages, updateSnaps]); 309 | 310 | useEffect(() => { 311 | if (!host) return; 312 | 313 | const hasIntersected = new Set(); 314 | 315 | const intersectionObserver = new IntersectionObserver( 316 | (entries) => { 317 | for (const entry of entries) { 318 | if (entry.isIntersecting && !hasIntersected.has(entry.target)) { 319 | hasIntersected.add(entry.target); 320 | } 321 | if (!entry.isIntersecting) { 322 | hasIntersected.delete(entry.target); 323 | } 324 | } 325 | }, 326 | { 327 | root: host, 328 | threshold: 0.6, 329 | }, 330 | ); 331 | const children = getItems({ includeClones: true }); 332 | for (let child of children) { 333 | intersectionObserver.observe(child); 334 | } 335 | 336 | function handleScrollEnd() { 337 | if (hasIntersected.size === 0) return; 338 | const sorted = [...hasIntersected].sort((a, b) => { 339 | return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING 340 | ? -1 341 | : 1; 342 | }); 343 | const firstIntersecting = sorted[0]; 344 | 345 | if ( 346 | loop === "infinite" && 347 | firstIntersecting.hasAttribute("data-clone") && 348 | !(mouseDragging && isDraggingRef?.current) 349 | ) { 350 | const cloneIndex = firstIntersecting.getAttribute("data-carousel-item"); 351 | const actualItem = getItems().find( 352 | (el) => el.getAttribute("data-carousel-item") === cloneIndex, 353 | ); 354 | requestAnimationFrame(() => { 355 | requestAnimationFrame(() => { 356 | scrollToItem(actualItem!, "instant"); 357 | }); 358 | }); 359 | } else { 360 | const indexString = (firstIntersecting as HTMLElement).dataset 361 | .carouselItem; 362 | 363 | if (process.env.NODE_ENV !== "production") { 364 | if (!indexString) { 365 | throw new Error( 366 | "Failed to find data-carousel-item HTML attribute on an item.", 367 | ); 368 | } 369 | } 370 | 371 | setActivePageIndex((prev) => { 372 | const slideIndex = parseInt(indexString!, 10); 373 | const activePage = pages.findIndex((page) => page[0] === slideIndex); 374 | const newIndex = clamp(0, activePage, getItems().length); 375 | if (prev !== newIndex) { 376 | onActivePageIndexChange?.({ index: newIndex }); 377 | } 378 | return newIndex; 379 | }); 380 | } 381 | } 382 | 383 | // Ideally we'd use the 'scrollend' event here. 384 | // However, some browsers will call the 'scrollend' handler *before* 385 | // snapping has settled. So in effect, the user will release the scroll, 386 | // then 'scrollend' event is called, and the element continues to scroll 387 | // to the closest snap position 388 | // 389 | // This will let us check whether scrolling has actually stopped and 390 | // whether the user is still dragging 391 | let timeout: any; 392 | function handleScroll() { 393 | clearTimeout(timeout); 394 | timeout = setTimeout(() => { 395 | if (mouseDragging && isDraggingRef?.current) return; 396 | handleScrollEnd(); 397 | }, 150); 398 | } 399 | 400 | host.addEventListener("scroll", handleScroll, { passive: true }); 401 | return () => { 402 | clearTimeout(timeout); 403 | for (let child of children) { 404 | intersectionObserver.unobserve(child); 405 | } 406 | intersectionObserver.disconnect(); 407 | host.removeEventListener("scroll", handleScroll); 408 | }; 409 | }, [ 410 | getItems, 411 | isDraggingRef, 412 | loop, 413 | mouseDragging, 414 | onActivePageIndexChange, 415 | pages, 416 | scrollToItem, 417 | host, 418 | ]); 419 | 420 | return useMemo( 421 | () => ({ 422 | itemsPerPage, 423 | activePageIndex, 424 | scrollBy, 425 | pages, 426 | next, 427 | prev, 428 | scrollToPage, 429 | }), 430 | [activePageIndex, itemsPerPage, next, pages, prev, scrollBy, scrollToPage], 431 | ); 432 | } 433 | --------------------------------------------------------------------------------