FairGarden Design

Carousel

Secondary items in short horizontal space: similar species, related products, photo sets, at least four. Base UI has no carousel, so it is composed from a Scroll Area in its rail kind (the track and a 2 px progress rail), a list of slides each labelled "n of N", Previous and Next buttons and a polite count. It never autoplays or loops, and it has no dots.

import { Carousel, CarouselPhoto, CarouselSlide } from '@fairgarden-private/design/components/Carousel'

The first slide aligns to the container's start edge and the end bleeds to the viewport edge (to the field's edge inside a forest or royal field), with the next slide peeking. Swipe, trackpad, the buttons and the arrow, Home and End keys on the focused track move it a slide at a time with snap; a vertical wheel never moves the track and scrolls the page. At either end that button is hidden with its space kept, and the count states the position. When every slide fits, the controls go and the slides sit as a static row.

Card carousels

Card carousels

kind="cards" holds CarouselSlides. Counts come from the carousel's own width and only rise with it: image and portrait cards run 2 + peek (1 + peek below 360 px); with post, editorial post cards run 1 at about 85% at base, 2 + peek from 768 px and 3 + peek from 1024 px. Without container queries the slide widths are percentages of the track, so the peek still holds.

CarouselCards.tsx
import { Carousel, CarouselSlide } from '@fairgarden-private/design/components/Carousel'
import {
  Card,
  CardMedia,
  CardMeta,
  CardTitle,
  CardTitleLink,
} from '@fairgarden-private/design/components/Card'
import styles from './cards.module.css'

const portrait =
  "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 3 4'%3E%3Crect width='3' height='4' fill='%23b7c4a8'/%3E%3Ccircle cx='1.5' cy='1.6' r='.8' fill='%23627a55'/%3E%3C/svg%3E"
const landscape =
  "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 3 2'%3E%3Crect width='3' height='2' fill='%23c9c1a6'/%3E%3Cpath d='M0 1.4 .9.9l.7.4.8-.6.6.5V2H0z' fill='%237a6d4a'/%3E%3C/svg%3E"

const species = [
  ['Hermit Thrush', 'Catharus guttatus'],
  ['Veery', 'Catharus fuscescens'],
  ['Swainson’s Thrush', 'Catharus ustulatus'],
  ['Gray-cheeked Thrush', 'Catharus minimus'],
  ['Bicknell’s Thrush', 'Catharus bicknelli'],
  ['American Robin', 'Turdus migratorius'],
]

const posts = [
  ['The river comes back', 'Feature · 12 min read'],
  ['What an easement protects', 'Explainer · 6 min read'],
  ['A year on the prairie', 'Photo essay · 9 min read'],
  ['Counting owls after dark', 'Field notes · 4 min read'],
  ['Seed libraries, explained', 'Explainer · 5 min read'],
]

/** Portrait species cards (2 + peek) and editorial post cards (`post`: 3 + peek from 1024 px of container). */
export function CarouselCards() {
  return (
    <div className={styles.stack}>
      <Carousel
        kind="cards"
        label="Similar species"
        header={<h3 className={styles.head}>Similar Species</h3>}
      >
        {species.map(([name, latin]) => (
          <CarouselSlide key={name}>
            <Card>
              <CardMedia>
                {/* A data-URI stand-in photo; next/image adds nothing here. */}
                <img src={portrait} alt="" />
              </CardMedia>
              <CardTitle>
                <CardTitleLink href="#species">{name}</CardTitleLink>
              </CardTitle>
              <CardMeta>
                <i>{latin}</i>
              </CardMeta>
            </Card>
          </CarouselSlide>
        ))}
      </Carousel>
      <Carousel
        kind="cards"
        post
        label="Related stories"
        header={<h3 className={styles.head}>Related Stories</h3>}
      >
        {posts.map(([title, meta]) => (
          <CarouselSlide key={title}>
            <Card>
              <CardMedia>
                <img src={landscape} alt="" />
              </CardMedia>
              <CardTitle>
                <CardTitleLink href="#post">{title}</CardTitleLink>
              </CardTitle>
              <CardMeta>{meta}</CardMeta>
            </Card>
          </CarouselSlide>
        ))}
      </Carousel>
    </div>
  )
}

Photo carousel

kind="photos" holds CarouselPhotos: a photo figure whose caption stays visible, right-flush. Below 1024 px of container each slide is about 88% of the track. From 1024 px the carousel becomes a fixed strip: a 480 px track height, each slide as wide as its photo at that height (from ratio), so nothing is cropped, with 12 px gaps.

CarouselPhotos.tsx
import { Carousel, CarouselPhoto } from '@fairgarden-private/design/components/Carousel'

function stand(width: number, height: number, sky: string, land: string) {
  return `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 ${width} ${height}'%3E%3Crect width='${width}' height='${height}' fill='%23${sky}'/%3E%3Cpath d='M0 ${height * 0.7}L${width * 0.3} ${height * 0.45}L${width * 0.55} ${height * 0.62}L${width} ${height * 0.4}V${height}H0z' fill='%23${land}'/%3E%3C/svg%3E`
}

const photos = [
  {
    ratio: 3 / 2,
    caption: 'Adult male in spring, singing from a low perch.',
    credit: 'Photo: A. Díaz',
  },
  {
    ratio: 4 / 5,
    caption: 'Nest of dead leaves and mud in a sapling fork.',
    credit: 'Photo: J. Okafor',
  },
  {
    ratio: 16 / 9,
    caption: 'Breeding habitat: mature eastern hardwood forest.',
    credit: 'Photo: M. Chen',
  },
  {
    ratio: 1,
    caption: 'Juvenile, spotted on the back as well as the breast.',
    credit: 'Photo: A. Díaz',
  },
  { ratio: 3 / 2, caption: 'Foraging in leaf litter along a stream.', credit: 'Photo: R. Silva' },
  {
    ratio: 2 / 3,
    caption: 'Winter range in the lowland forests of Central America.',
    credit: 'Photo: L. Park',
  },
  { ratio: 3 / 2, caption: 'Banding station, early May.', credit: 'Photo: J. Okafor' },
  { ratio: 5 / 4, caption: 'Eggs: three to four, pale blue.', credit: 'Photo: M. Chen' },
]

/** Eight photos: ≈ 88% single slides, and from 1024 px of container a 480 px strip at each photo's own ratio. */
export function CarouselPhotos() {
  return (
    <Carousel kind="photos" label="Wood Thrush photos" printUrl="example.org/wood-thrush/photos">
      {photos.map((photo, index) => (
        <CarouselPhoto
          key={photo.caption}
          src={stand(
            Math.round(photo.ratio * 120),
            120,
            index % 2 ? 'b7c4a8' : 'c9c1a6',
            index % 2 ? '627a55' : '7a6d4a',
          )}
          alt={photo.caption}
          ratio={photo.ratio}
          caption={photo.caption}
          credit={photo.credit}
        />
      ))}
    </Carousel>
  )
}

In print

Controls, count, peek and rail are hidden. Card slides print as a 2-up grid; photo sets of up to 6 print 3-up at their own ratios. A larger photo set prints its first slide and caption and "n more images (printUrl)".

API Reference

The carousel. Swipe, trackpad, the Previous and Next buttons, and the arrow, Home and End keys on the focused track all move it one slide at a time with snap. In print, card slides become a 2-up grid and photo sets of up to 6 a 3-up grid; a larger photo set prints its first slide and “n more images (short URL)".

PropTypeDescription
label
string

The carousel’s accessible name, e.g. “Similar species”.

controlLabels
[string, string] | undefined

Previous and Next accessible names. Default “Previous” and “Next”.

header
React.ReactNode | undefined

The module header (§11.8), above the track.

kind
'cards' | 'photos'

cards: §12.2 cards, 2 portrait + peek (1 + peek below 360 px of container). photos: captioned figures at ≈ 88%, and from 1024 px a fixed 480 px strip whose slides keep their photos’ own ratios.

post
boolean | undefined

Editorial post cards: 1 landscape slide at ≈ 85% at base, 2 + peek from 768 px, 3 + peek from 1024 px of container [D186]. Default false.

primary
| 'ruby'
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| null
| undefined

Primary Radix scale: buttons, count, rail and thumb, and the slides’ inherited primary. Never defaulted; omitted, it inherits the scope [D133].

printUrl
string | undefined

The short URL printed after a photo set of more than 6 slides: “5 more images (example.org/gallery)". No protocol.

secondary
| 'ruby'
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| 'amber'
| 'blue'
| 'cyan'
| 'grass'
| 'green'
| 'jade'
| 'lime'
| 'mint'
| 'orange'
| 'sky'
| 'teal'
| 'yellow'
| null
| undefined

Secondary Radix scale, passed to the slides; no carousel part uses it. Never defaulted.

children
React.ReactNode

The slides: CarouselSlides (cards) or CarouselPhotos (photos), at least four.

CarouselSlide

One slide, labelled “n of N” for assistive technology. It snaps to its start; slides are focusable only through their own links.

PropTypeDescription
ratio
number | undefined

The photo’s width ÷ height (e.g. 3 / 2). From 1024 px of container the photo strip sizes the slide from it at the 480 px track height, so nothing is cropped [D186].

CarouselPhoto

A photo slide: a §8.5 photo Figure with the image at its own ratio and the caption right-flush under it.

PropTypeDescription
alt
string

The alternative text; empty only for a decorative photo.

caption
React.ReactNode | undefined

The caption: what to notice. Always visible, right-flush.

credit
React.ReactNode | undefined

The credit, in the same run: “Photo: Name / Program”.

imgProps
| Omit<
    React.DetailedHTMLProps<
      React.ImgHTMLAttributes<HTMLImageElement>,
      HTMLImageElement
    >,
    'src' | 'alt'
  >
| undefined

Image srcSet, sizes, loading and other attributes.

ratio
number

The photo’s own width ÷ height (e.g. 3 / 2), so the strip never crops it.

src
string

The image source.

Additional types

carousel
CarouselPhotoProps

Props for CarouselPhoto: the image, its ratio and the caption.

type CarouselPhotoProps = {
  /** The image source. */
  src: string;
  /** The alternative text; empty only for a decorative photo. */
  alt: string;
  /** The photo's own width ÷ height (e.g. `3 / 2`), so the strip never crops it. */
  ratio: number;
  /** The caption: what to notice. Always visible, right-flush. */
  caption?: React.ReactNode;
  /** The credit, in the same run: "Photo: Name / Program". */
  credit?: React.ReactNode;
  /** Image `srcSet`, `sizes`, `loading` and other attributes. */
  imgProps?: Omit<
    React.DetailedHTMLProps<React.ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement>,
    'src' | 'alt'
  >;
}
CarouselProps

Props for Carousel: section props, the kind (and post for cards), label, header, slides and the color axes.

type CarouselProps = CarouselCardsProps | CarouselPhotosProps
CarouselSlideProps

Props for CarouselSlide: li props and, for photo slides, the photo’s ratio.

type CarouselSlideProps = React.ClassAttributes<HTMLLIElement> &
  React.LiHTMLAttributes<HTMLLIElement> & { ratio?: number }

Specification: DESIGN-SYSTEM.md §12.4 (carousel; desktop counts and the photo strip per D186), §10.19 (scroll area) and §5.10.2 (module reflow).