Skip to content
Star on GitHub
Forms and inputsCarousel
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises, from Astro sites to React apps and AI products.

Book a call

Carousel

Drafts the agent made to keep one of, or a gallery of pictures, on one swipeable track. Free and MIT licensed, as HTML, a typed React component or an Astro component, in two styles with light and dark mode.

$npx designme add carousel
Cover image draftsMaking 4 variants

01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add carousel

React

$npx designme add carousel --react

Astro

$npx designme add carousel --astro

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro islands. The Astro version lands in src/components/designme too, with the same markup, CSS and script as the HTML version. See Astro.

shadcn CLI, React

$npx shadcn@latest add https://designme.dev/r/shadcn/carousel.json

shadcn CLI, Astro

$npx shadcn@latest add https://designme.dev/r/shadcn/astro/carousel.json

In a project set up for shadcn/ui, its CLI adds the same files to your components folder, under designme/, with the shared core. To give the components your shadcn or Tailwind v4 theme, add the theme bridge.

02

About

What it is for and what it does.

One carousel with two types, picked with the switch under it. Variants are the pictures an agent made from one brief, in a panel with a title, to browse and keep one. Images is a gallery of pictures with their captions. Both run on the same native scroll-snap track: each slide is a framed picture with its caption, the next one peeking in, with Previous and Next disabled at the ends and a row of dots under the track, the current one drawn longer. A picture sliding into view comes into focus: it is soft and faded while it is mostly out of view and sharpens as it arrives. Blueprint never blurs, so there a veil wipes off it from the left. It doesn't play on its own.

  • Variants: when the agent makes several versions of the same picture (a cover image, an illustration, a product shot) and a person picks one, as they arrive one by one, each able to fail on its own.
  • Images: for a set of pictures people look at one by one, like generated images, product shots, a moodboard or screenshots of a design, each with a caption.
  • Where a row of pictures should take one line of the page and let people swipe through the rest.
03

Usage

Import it in React and drive it from your own state, or place it in an Astro page.

React

import { Carousel } from '@/components/designme/carousel/Carousel';

React example

The demo above as a React component, 57 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component, with its Variants / Images switch under it.
// Variants: four drafts of a cover image arrive (the last one fails), then the person flips to the third and keeps it.
// Images: the next two pictures, the last one from the dots, then back one. Each picture here is a gradient of its colours;
// pass `src` for real images (designme.dev lays its own generated pictures over the same gradients).
import { useEffect, useRef, useState } from 'react';
import { Carousel, type CarouselImage, type Variant } from './Carousel';
import { Seg } from '../lib/motion';
import { Demo } from '../lib/Demo';
import { useStory, useWait } from '../lib/useScript';

const sky = (a: string, b: string, c: string, d: string, x?: number, y = 50) =>
  `${x == null ? '' : `radial-gradient(circle at ${x}% ${y}%,#fff3d6 0 4%,transparent 24%),`}linear-gradient(${a},${b} 42%,${c} 58%,${d} 60%,color-mix(in srgb,${d} 60%,#000))`;
const START: Variant[] = [
  { key: 'a', label: 'Variant A', meta: 'Coral sky · 9s', status: 'queued', image: { alt: 'Mountains under a coral and amber sky, the sun low on the right', placeholder: sky('#ff6f61', '#ffb15e', '#ffe6a8', '#c86a68', 70, 52) } },
  { key: 'b', label: 'Variant B', meta: 'Cool morning · 11s', status: 'queued', image: { alt: 'Blue mountains under a clear morning sky, the sun rising on the left', placeholder: sky('#2b6cff', '#7fb2ff', '#ffe1c2', '#5a70cc', 28, 52) } },
  { key: 'c', label: 'Variant C', meta: 'Dusk · 7s', status: 'queued', image: { alt: 'Dark violet ridges at dusk, a pink sky and the sun setting between them', placeholder: sky('#14233f', '#e0577a', '#ffb36b', '#8a3a70', 50, 52) } },
  { key: 'd', label: 'Variant D', meta: 'Mint haze · 14s', status: 'queued', image: { alt: 'Green mountains in a mint haze, a pale sun above them', placeholder: sky('#0f8f7f', '#7fe0b8', '#fff2b8', '#3f9f86', 62, 52) } }
];
const IMAGES: CarouselImage[] = [
  { key: 'dunes', title: 'Dunes at dusk', caption: 'Plum, coral and amber', alt: 'Sand dunes under a pink and orange sky, a low sun on the right', placeholder: sky('#2a1b5e', '#c2417a', '#ff9a5a', '#d9894f', 68) },
  { key: 'alpine', title: 'Alpine dawn', caption: 'Periwinkle and peach', alt: 'Layers of violet mountains at dawn, the light rising behind them on the left', placeholder: sky('#3b5bdb', '#9b8cff', '#ffc2a8', '#5a4bc8', 30) },
  { key: 'aurora', title: 'Aurora over the lake', caption: 'Midnight blue and green', alt: 'Green northern lights over dark hills, mirrored in a still lake under the stars', placeholder: sky('#050b2a', '#0d2a5c', '#1f8f7a', '#0a1838') },
  { key: 'sea', title: 'Coral sea', caption: 'Rose, gold and indigo', alt: 'The sun setting over a calm sea, a headland on the left and a rose and gold sky', placeholder: sky('#ff5f8f', '#ff8a6a', '#ffc46b', '#4a3a9a', 42) },
  { key: 'hills', title: 'Green hills', caption: 'Sky blue and leaf green', alt: 'Rolling green hills under a blue sky with a few white clouds', placeholder: sky('#3fa7ff', '#a8dcff', '#e6f7ff', '#3fb85a') },
  { key: 'canyon', title: 'Violet canyon', caption: 'Pink, orange and violet', alt: 'Layered canyon ridges in pink, red and violet under a glowing orange sky', placeholder: sky('#ff7ac6', '#ffb36b', '#ffe08a', '#b23a86', 55) }
];

export default function CarouselDemo() {
  const ref = useRef<HTMLDivElement>(null);
  const wait = useWait();
  const [type, setType] = useState<'variants' | 'images'>('variants');
  const [variants, setVariants] = useState(START);
  const [kept, setKept] = useState<string | null>(null);
  const set = (key: string, v: Partial<Variant>) => setVariants(vs => vs.map(x => x.key === key ? { ...x, ...v } : x));
  const make = async (key: string, i: number, fail = false) => {
    set(key, { status: 'generating' }); await wait(1100 + i * 150);
    set(key, fail ? { status: 'failed' } : { status: 'ready' });
  };
  // the agent makes them one after another; the last one fails once
  useEffect(() => { void (async () => { await wait(500); for (const [i, v] of START.entries()) await make(v.key, i, v.key === 'd'); })(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
  // the story plays the drafts; once you switch, the carousel is yours
  useStory(ref, async ({ wait, press }) => {
    await wait(7200);
    press('[aria-label="Next variant"]'); await wait(1000);
    press('[aria-label="Next variant"]'); await wait(1000);
    press('.dm-crs-s:nth-child(3) .dm-crs-keep');
  });
  return (
    <Demo ref={ref} switcher={<Seg label="Type" value={type} onChange={setType} options={[{ value: 'variants', label: 'Variants' }, { value: 'images', label: 'Images' }]} />}>
      {type === 'variants'
        ? <Carousel type="variants" title="Cover image drafts" variants={variants} kept={kept} onKeptChange={setKept} onRetry={key => { void make(key, 3); }} />
        : <Carousel type="images" images={IMAGES} />}
    </Demo>
  );
}

Astro

In a page in src/pages. It plays the demo above; change its carousel.js to show your own data. Attributes like class go on its root element.

---
import Carousel from '../components/designme/carousel/Carousel.astro';
---
<Carousel />
<Carousel data-style="soft" class="my-class" />
04

Props

Everything Carousel takes.

CarouselBaseProps

The picture. Without it, `placeholder` stands in */ src?: string; /** What the picture shows, for screen readers */ alt: string; /** The caption, like "Dunes at dusk" */ title: string; /** A quieter line under the title, like "Plum, coral and amber" */ caption?: string; /** A CSS background drawn under the picture while it loads (or instead of it), like a gradient of its colours */ placeholder?: string; /** This slide's width, overriding `slideWidth`, like 220 or "40%" */ width?: number | string; } export interface VariantImage { /** The picture. Without it, `placeholder` stands in */ src?: string; /** What the picture shows, for screen readers */ alt: string; /** A CSS background drawn under the picture while it loads (or instead of it), like a gradient of its colours */ placeholder?: string; } export interface Variant { key: string; /** Like "Variant A" */ label: string; /** What sets it apart and how long it took, like "Coral sky · 9s" */ meta?: string; /** queued and generating show an empty frame (a light sweeps across it while generating); failed shows the error and Retry */ status: 'queued' | 'generating' | 'ready' | 'failed'; /** The picture, shown once ready: it comes into focus in a 3:2 frame */ image?: VariantImage; /** Anything else to show once ready, in place of `image` */ content?: ReactNode; /** Why it failed. Defaults to "The image model timed out" */ error?: string; } /** What both types take

PropTypeWhat it does
indexnumberThe first slide in view, from 0
defaultIndexnumber
onIndexChange(index: number) => voidCalled with the first slide in view once the track settles somewhere new
slideWidthnumber | stringEach slide's width: "80%" for images and "84%" for variants (the next one peeks in), "100%" shows one at a time, a number is pixels
align'start' | 'center'Where a slide rests: at the start of the track (the default) or in its middle
gapnumberThe space between slides, in pixels (16 for images, 12 for variants)
arrowsbooleanPrevious and Next (true)
dotsbooleanA dot for each place the track can rest, under it (true)
classNamestring

CarouselVariantsProps

The first slide in view, from 0 */ index?: number; defaultIndex?: number; /** Called with the first slide in view once the track settles somewhere new */ onIndexChange?: (index: number) => void; /** Each slide's width: "80%" for images and "84%" for variants (the next one peeks in), "100%" shows one at a time, a number is pixels */ slideWidth?: number | string; /** Where a slide rests: at the start of the track (the default) or in its middle */ align?: 'start' | 'center'; /** The space between slides, in pixels (16 for images, 12 for variants) */ gap?: number; /** Previous and Next (true) */ arrows?: boolean; /** A dot for each place the track can rest, under it (true) */ dots?: boolean; className?: string; } /** `type="variants"`: the drafts an agent made from one brief, in a panel with its title, to browse and keep one

PropTypeWhat it does
typerequired'variants'
titlerequiredstringLike "Cover image drafts"
variantsrequiredVariant[]
subtitlestringThe line under the title. Defaults to "Making 4 variants, 1 ready", then "3 ready, 1 failed", then "Keeping Variant C"
keptstring | nullThe kept variant's key, or null
defaultKeptstring | null
onKeptChange(key: string | null) => void
onRetry(key: string) => voidAdds Retry to a failed variant

Also takes the props of CarouselBaseProps.

CarouselImagesProps

Like "Cover image drafts" */ title: string; variants: Variant[]; /** The line under the title. Defaults to "Making 4 variants, 1 ready", then "3 ready, 1 failed", then "Keeping Variant C" */ subtitle?: string; /** The kept variant's key, or null */ kept?: string | null; defaultKept?: string | null; onKeptChange?: (key: string | null) => void; /** Adds Retry to a failed variant */ onRetry?: (key: string) => void; } /** `type="images"`: a gallery of pictures with their captions

PropTypeWhat it does
typerequired'images'
imagesrequiredCarouselImage[]
labelstringThe gallery's name for screen readers. Defaults to "Gallery"
heightnumberOne height for every picture, in pixels, for slides of mixed widths. Without it each picture is 3:2

Also takes the props of CarouselBaseProps.

05

Code

The full source, as the CLI installs it.

Carousel.tsxReact · 207 lines
'use client';
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useCarousel } from '../lib/carousel';
import { useControllable } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Spin, Swap, Tip } from '../lib/motion';
import '../designme.css';
import './carousel.css';

export interface CarouselImage {
  key: string;
  /** The picture. Without it, `placeholder` stands in */
  src?: string;
  /** What the picture shows, for screen readers */
  alt: string;
  /** The caption, like "Dunes at dusk" */
  title: string;
  /** A quieter line under the title, like "Plum, coral and amber" */
  caption?: string;
  /** A CSS background drawn under the picture while it loads (or instead of it), like a gradient of its colours */
  placeholder?: string;
  /** This slide's width, overriding `slideWidth`, like 220 or "40%" */
  width?: number | string;
}

export interface VariantImage {
  /** The picture. Without it, `placeholder` stands in */
  src?: string;
  /** What the picture shows, for screen readers */
  alt: string;
  /** A CSS background drawn under the picture while it loads (or instead of it), like a gradient of its colours */
  placeholder?: string;
}

export interface Variant {
  key: string;
  /** Like "Variant A" */
  label: string;
  /** What sets it apart and how long it took, like "Coral sky · 9s" */
  meta?: string;
  /** queued and generating show an empty frame (a light sweeps across it while generating); failed shows the error and Retry */
  status: 'queued' | 'generating' | 'ready' | 'failed';
  /** The picture, shown once ready: it comes into focus in a 3:2 frame */
  image?: VariantImage;
  /** Anything else to show once ready, in place of `image` */
  content?: ReactNode;
  /** Why it failed. Defaults to "The image model timed out" */
  error?: string;
}

/** What both types take */
export interface CarouselBaseProps {
  /** The first slide in view, from 0 */
  index?: number;
  defaultIndex?: number;
  /** Called with the first slide in view once the track settles somewhere new */
  onIndexChange?: (index: number) => void;
  /** Each slide's width: "80%" for images and "84%" for variants (the next one peeks in), "100%" shows one at a time, a number is pixels */
  slideWidth?: number | string;
  /** Where a slide rests: at the start of the track (the default) or in its middle */
  align?: 'start' | 'center';
  /** The space between slides, in pixels (16 for images, 12 for variants) */
  gap?: number;
  /** Previous and Next (true) */
  arrows?: boolean;
  /** A dot for each place the track can rest, under it (true) */
  dots?: boolean;
  className?: string;
}

/** `type="variants"`: the drafts an agent made from one brief, in a panel with its title, to browse and keep one */
export interface CarouselVariantsProps extends CarouselBaseProps {
  type: 'variants';
  /** Like "Cover image drafts" */
  title: string;
  variants: Variant[];
  /** The line under the title. Defaults to "Making 4 variants, 1 ready", then "3 ready, 1 failed", then "Keeping Variant C" */
  subtitle?: string;
  /** The kept variant's key, or null */
  kept?: string | null;
  defaultKept?: string | null;
  onKeptChange?: (key: string | null) => void;
  /** Adds Retry to a failed variant */
  onRetry?: (key: string) => void;
}

/** `type="images"`: a gallery of pictures with their captions */
export interface CarouselImagesProps extends CarouselBaseProps {
  type: 'images';
  images: CarouselImage[];
  /** The gallery's name for screen readers. Defaults to "Gallery" */
  label?: string;
  /** One height for every picture, in pixels, for slides of mixed widths. Without it each picture is 3:2 */
  height?: number;
}

export type CarouselProps = CarouselVariantsProps | CarouselImagesProps;

const size = (v: number | string | undefined) => typeof v === 'number' ? `${v}px` : v;
const bg = (src?: string, placeholder?: string) => [src && `url("${src}")`, placeholder].filter(Boolean).join(', ') || undefined;

/** One carousel, two types, on the same native scroll-snap track: a swipe, a trackpad, a mouse drag, the arrows, the dots and
    the keys all move it, and each slide is a framed picture with its caption, the next one peeking in. A picture comes into
    focus as it slides into view (Blueprint wipes a veil off it instead). `type="variants"` shows the drafts an agent made,
    each queued, generating, ready or failed, to keep one; `type="images"` is a gallery. */
export function Carousel(props: CarouselProps) {
  return props.type === 'images' ? <Images {...props} /> : <Variants {...props} />;
}

function Nav({ k, noun }: { k: ReturnType<typeof useCarousel>; noun: string }) {
  return (
    <>
      <Tip label="Previous"><button type="button" className="dm-pill dm-line dm-crs-nav dm-crs-prev" aria-label={`Previous ${noun}`} disabled={k.at === 0} onClick={() => k.go(k.at - 1)}><Icon name="chev-l" /></button></Tip>
      <Tip label="Next"><button type="button" className="dm-pill dm-line dm-crs-nav dm-crs-next" aria-label={`Next ${noun}`} disabled={k.at === k.stops.length - 1} onClick={() => k.go(k.at + 1)}><Icon name="chev-r" /></button></Tip>
    </>
  );
}

function Dots({ k, label }: { k: ReturnType<typeof useCarousel>; label: (slide: number) => string }) {
  if (k.stops.length < 2) return null;
  return <div className="dm-crs-dots">{k.stops.map((s, j) => <button type="button" key={s.i} aria-label={label(s.i)} aria-current={j === k.at} onClick={() => k.go(j)} />)}</div>;
}

const vars = (p: CarouselBaseProps & { height?: number }) => ({
  '--dm-w': size(p.slideWidth), '--dm-gap': p.gap != null ? `${p.gap}px` : undefined, '--dm-h': p.height != null ? `${p.height}px` : undefined
} as CSSProperties);

function Images(p: CarouselImagesProps) {
  const { images, index, defaultIndex = 0, onIndexChange, label = 'Gallery', align = 'start', arrows = true, dots = true, className } = p;
  const tr = useRef<HTMLDivElement>(null);
  const n = images.length;
  const k = useCarousel(tr, { count: n, index, defaultIndex, onIndexChange });
  const said = k.settled != null ? images[k.settled] : undefined;
  return (
    <div data-dm="" className={cx('dm-crs-c', className)} data-type="images" data-align={align === 'center' ? 'center' : undefined} style={vars(p)} role="region" aria-roledescription="carousel" aria-label={label}>
      {arrows && <div className="dm-crs-h"><Nav k={k} noun="picture" /></div>}
      <div ref={tr} className={cx('dm-crs-tr', k.dragging && 'dm-drag')} role="group" tabIndex={0} aria-label="Pictures" {...k.trackProps}>
        {images.map((im, i) => (
          <div key={im.key} className={cx('dm-crs-s', k.inView[i] && 'dm-in')} role="group" aria-roledescription="slide" aria-label={`${i + 1} of ${n}`}
            style={im.width != null ? { '--dm-w': size(im.width) } as CSSProperties : undefined}>
            <figure className="dm-crs-fig">
              <div className="dm-crs-pic" role="img" aria-label={im.alt} style={{ backgroundImage: bg(im.src, im.placeholder) }} />
              <figcaption className="dm-crs-cap"><b>{im.title}</b>{im.caption != null && <span className="dm-muted">{im.caption}</span>}</figcaption>
            </figure>
          </div>
        ))}
      </div>
      {dots && <Dots k={k} label={i => `Show ${images[i]?.title}`} />}
      <p className="dm-crs-sr" aria-live="polite">{said ? `${said.title}, ${(k.settled ?? 0) + 1} of ${n}` : ''}</p>
    </div>
  );
}

function Variants(p: CarouselVariantsProps) {
  const { title, variants, subtitle, kept, defaultKept = null, onKeptChange, index, defaultIndex = 0, onIndexChange, onRetry, align = 'start', arrows = true, dots = true, className } = p;
  const [keep, setKeep] = useControllable(kept, defaultKept, onKeptChange);
  const tr = useRef<HTMLDivElement>(null);
  const n = variants.length;
  const toggle = (v: Variant | undefined) => { if (v?.status === 'ready') setKeep(keep === v.key ? null : v.key); };
  // Enter on the track keeps the draft in view
  const k = useCarousel(tr, { count: n, index, defaultIndex, onIndexChange, onKey: (e, slide) => { if (e.key === 'Enter') { e.preventDefault(); toggle(variants[slide]); } } });
  // each change worth hearing goes to a polite live region: a variant arriving or failing, one kept, the track settling
  const [said, setSaid] = useState('');
  const was = useRef(variants.map(v => v.status));
  useEffect(() => {
    variants.forEach((v, i) => { if (was.current[i] !== v.status && (v.status === 'ready' || v.status === 'failed')) setSaid(`${v.label} ${v.status === 'ready' ? 'ready' : 'failed'}`); });
    was.current = variants.map(v => v.status);
  }, [variants]);
  const keptRef = useRef(keep);
  useEffect(() => { if (keptRef.current !== keep) setSaid(keep ? `Kept ${variants.find(v => v.key === keep)?.label}` : 'Nothing kept'); keptRef.current = keep; }, [keep]); // eslint-disable-line react-hooks/exhaustive-deps
  useEffect(() => { if (k.settled != null && variants[k.settled]) setSaid(`${variants[k.settled].label}, ${k.settled + 1} of ${n}`); }, [k.settled]); // eslint-disable-line react-hooks/exhaustive-deps
  const ready = variants.filter(v => v.status === 'ready').length, bad = variants.filter(v => v.status === 'failed').length, busy = variants.some(v => v.status === 'queued' || v.status === 'generating');
  const line = subtitle ?? (keep ? `Keeping ${variants.find(v => v.key === keep)?.label}` : busy ? `Making ${n} variants, ${ready} ready` : `${ready} ready${bad ? `, ${bad} failed` : ''}`);
  return (
    <div data-dm="" className={cx('dm-crs-c dm-panel', keep && 'dm-kept', className)} data-type="variants" data-align={align === 'center' ? 'center' : undefined} style={vars(p)} role="region" aria-roledescription="carousel" aria-label={title}>
      <div className="dm-crs-h">
        <div className="dm-crs-t"><b>{title}</b><Swap className="dm-muted dm-crs-sub">{line}</Swap></div>
        {arrows && <Nav k={k} noun="variant" />}
      </div>
      <div ref={tr} className={cx('dm-crs-tr', k.dragging && 'dm-drag')} role="group" tabIndex={0} aria-label="Variants. Enter keeps the one in view" {...k.trackProps}>
        {variants.map((v, i) => (
          <div key={v.key} className={cx('dm-crs-s', keep === v.key && 'dm-kept', k.inView[i] && 'dm-in')} role="group" aria-roledescription="slide" aria-label={`${i + 1} of ${n}`}>
            <figure className="dm-crs-fig">
              {v.status === 'failed'
                ? <div className="dm-crs-pv dm-fail"><Icon name="alert" /><b>Couldn't make this picture</b><span className="dm-muted">{v.error ?? 'The image model timed out'}</span>{onRetry && <button type="button" className="dm-pill dm-line dm-sm" onClick={() => onRetry(v.key)}><Icon name="refresh" size="xs" />Retry</button>}</div>
                : <div className={cx('dm-crs-pv', v.status === 'generating' && 'dm-gen')}>
                    {v.status !== 'ready' ? <Icon name="image" className="dm-ph" />
                      : v.image ? <span className="dm-crs-img" role="img" aria-label={v.image.alt} style={{ backgroundImage: bg(v.image.src, v.image.placeholder) }} />
                      : <div className="dm-enter" style={{ display: 'contents' }}>{v.content}</div>}
                  </div>}
              <figcaption className="dm-crs-f">
                <div><b>{v.label}</b><span className="dm-muted">{v.status === 'generating' ? 'Making' : v.status === 'queued' ? 'Waiting' : v.meta}</span></div>
                {v.status === 'ready'
                  ? <button type="button" className="dm-pill dm-line dm-sm dm-crs-keep" aria-pressed={keep === v.key} onClick={() => toggle(v)}>{keep === v.key ? <><Icon name="check" size="xs" />Kept</> : 'Keep'}</button>
                  : v.status === 'failed' ? <span className="dm-tag dm-bad">Failed</span> : v.status === 'generating' ? <span className="dm-tag"><Spin />Making</span> : <span className="dm-tag">Queued</span>}
              </figcaption>
            </figure>
          </div>
        ))}
      </div>
      {dots && <Dots k={k} label={i => `Go to ${variants[i]?.label}`} />}
      <p className="dm-crs-sr" aria-live="polite">{said}</p>
    </div>
  );
}
carousel.cssReact · 76 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Carousel ---------- */
/* one carousel, two types, picked with the switch under it (owner, 4 Nov). Both run on the same native scroll-snap track: a
   swipe, a trackpad, a mouse drag, the arrows, the dots and the keys all move it, and each slide is a framed picture with
   its caption, the next one peeking in. A picture coming into view comes into focus (Soft: out of a blur; Blueprint never
   blurs, so there a veil wipes off it).
   - Variants: the drafts an agent made from one brief, in a panel with its title. Each fills in as it arrives, can fail
     with Retry, and can be kept: Keep turns from an outline to a fill, its frame's edge steps up and the others step back.
   - Images: a gallery of pictures, bare, with Previous and Next above the track.
   --dm-w is a slide's width (80% for images, 84% for variants; 100% shows one at a time, a fixed width mixes sizes), --dm-gap the
   space between slides, --dm-h one height for every picture, and data-align="center" on .crs-c snaps slides to the middle */
.dm-crs-c{width:100%;min-width:0;display:flex;flex-direction:column;gap:var(--dm-s-3)}
.dm-crs-c[hidden]{display:none}
.dm-crs-c.dm-panel{padding:var(--dm-s-4) 0;--dm-w:84%;--dm-gap:var(--dm-s-3)}
.dm-crs-h{display:flex;align-items:center;justify-content:flex-end;gap:var(--dm-s-2)}
.dm-crs-c.dm-panel>.dm-crs-h,.dm-crs-c.dm-panel>.dm-crs-dots{padding:0 var(--dm-s-4)}
.dm-crs-t{flex:1;min-width:0;display:flex;flex-direction:column;font-size:13.5px;line-height:1.4}
.dm-crs-t b{font-weight:500}
.dm-crs-sub{font-size:12px}
.dm-crs-nav{width:32px;padding:0}
@media (hover:hover) and (pointer:fine){.dm-crs-nav:not(:disabled):hover{background:var(--dm-row)}}
.dm-crs-tr{position:relative;display:flex;gap:var(--dm-gap,var(--dm-s-4));overflow-x:auto;overscroll-behavior-x:contain;scroll-snap-type:x mandatory;scrollbar-width:none;border-radius:calc(14px * var(--dm-rk))}
.dm-crs-tr::-webkit-scrollbar{display:none}
.dm-crs-c.dm-panel>.dm-crs-tr{padding:0 var(--dm-s-4);scroll-padding-inline:var(--dm-s-4);border-radius:0}
.dm-crs-c.dm-panel>.dm-crs-tr:focus-visible{outline-offset:-2px;border-radius:var(--dm-r-in)}
.dm-crs-tr.dm-drag{scroll-snap-type:none;cursor:grabbing;user-select:none;-webkit-user-select:none}
.dm-crs-s{flex:0 0 var(--dm-w,80%);min-width:0;display:flex;scroll-snap-align:start;transition:opacity .35s var(--dm-ease)}
.dm-crs-c[data-align="center"] .dm-crs-s{scroll-snap-align:center}
.dm-crs-fig{flex:1;min-width:0;margin:0;display:flex;flex-direction:column;background:var(--dm-surface);border:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent);border-radius:calc(14px * var(--dm-rk));overflow:hidden;transition:border-color var(--dm-t-fast)}
.dm-crs-c.dm-panel .dm-crs-fig{border-radius:var(--dm-r-in)}
/* the picture (or, for a draft, its frame): out of view it is soft and faded, clipped to its frame so the blur never spills
   onto the caption; in view it comes into focus */
.dm-crs-pic,.dm-crs-pv{position:relative;aspect-ratio:var(--dm-ar,3/2);height:var(--dm-h,auto);background-color:var(--dm-row);clip-path:inset(0);transition:filter .7s var(--dm-ease),opacity .7s var(--dm-ease)}
.dm-crs-pic{background-image:var(--dm-art);background-size:cover;background-position:center}
.dm-crs-s:not(.dm-in) :is(.dm-crs-pic,.dm-crs-pv){filter:blur(10px);opacity:.6}
/* Blueprint's veil, the surface over a picture that is out of view, wipes off from the left as the picture comes in */
.dm-crs-pic::after,.dm-crs-pv::after{content:"";position:absolute;inset:0;z-index:1;background:var(--dm-surface);opacity:0;clip-path:inset(0 0 0 100%);pointer-events:none}
:root:not([data-style="soft"]) .dm-crs-s:not(.dm-in) :is(.dm-crs-pic,.dm-crs-pv):not(:where([data-style="soft"],[data-style="soft"] *)){opacity:1}
:root:not([data-style="soft"]) :is(.dm-crs-pic,.dm-crs-pv):not(:where([data-style="soft"],[data-style="soft"] *))::after{opacity:.6;transition:clip-path var(--dm-t-slow) var(--dm-ease)}
:root:not([data-style="soft"]) .dm-crs-s:not(.dm-in) :is(.dm-crs-pic,.dm-crs-pv):not(:where([data-style="soft"],[data-style="soft"] *))::after{clip-path:inset(0)}
/* the caption: the title over a quieter line, as a draft's label sits over its meta, so every slide keeps one height */
.dm-crs-cap{display:flex;flex-direction:column;padding:10px var(--dm-s-3);font-size:13px;line-height:1.35}
.dm-crs-cap b{font-weight:500;color:var(--dm-fg);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-crs-cap .dm-muted{font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
/* a draft is a 3:2 frame that holds its picture once it arrives (owner, 4 Nov). Until then it is a grey frame with an image
   mark; while the agent makes it a light sweeps across, and the picture comes into focus as it lands */
.dm-crs-pv{padding:var(--dm-s-4);display:flex;flex-direction:column;justify-content:center;gap:6px;overflow:hidden}
.dm-crs-pv>.dm-ph{align-self:center;width:24px;height:24px;color:var(--dm-faint)}
.dm-crs-pv.dm-gen::before{content:"";position:absolute;inset:0;background:linear-gradient(100deg,transparent 30%,color-mix(in srgb,var(--dm-surface) 70%,transparent) 50%,transparent 70%) 0 0/250% 100%;animation:dm-crssweep 1.6s var(--dm-ease-io) infinite}
@keyframes dm-crssweep{from{background-position:120% 0}to{background-position:-120% 0}}
.dm-crs-img{position:absolute;inset:0;background-image:var(--dm-art);background-size:cover;background-position:center;animation:dm-crsin .9s var(--dm-ease) both}
/* the picture comes into focus as it arrives; Blueprint never blurs, so there it wipes in from the left */
@keyframes dm-crsin{from{opacity:0;filter:blur(16px);transform:scale(1.06)}to{opacity:1;filter:none;transform:none}}
:root:not([data-style="soft"]) .dm-crs-img:not(:where([data-style="soft"],[data-style="soft"] *)){animation-name:dm-crswipe;animation-duration:var(--dm-t-slow)}
@keyframes dm-crswipe{from{clip-path:inset(0 100% 0 0)}to{clip-path:inset(0)}}
.dm-crs-pv.dm-fail{align-items:center;text-align:center;gap:var(--dm-s-1)}
.dm-crs-pv.dm-fail>.dm-i{color:var(--dm-bad)}
.dm-crs-pv.dm-fail b{font-size:13px;font-weight:500;color:var(--dm-fg)}
.dm-crs-pv.dm-fail .dm-muted{font-size:12px}
.dm-crs-pv.dm-fail .dm-pill{margin-top:6px}
.dm-crs-f{display:flex;align-items:center;gap:var(--dm-s-2);min-height:28px;padding:10px var(--dm-s-3)}
.dm-crs-f>div{flex:1;min-width:0;display:flex;flex-direction:column;font-size:13px;line-height:1.35}
.dm-crs-f b{font-weight:500}
.dm-crs-f .dm-muted{font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-crs-f .dm-tag .dm-spin{width:9px;height:9px}
.dm-crs-keep[aria-pressed="true"]{background:var(--dm-accent);color:var(--dm-on-accent);box-shadow:none}
.dm-crs-c.dm-kept .dm-crs-s:not(.dm-kept){opacity:.45}
.dm-crs-s.dm-kept .dm-crs-fig{border-color:color-mix(in srgb,var(--dm-pick-on) 70%,transparent)}
.dm-crs-dots{display:flex;justify-content:center;gap:6px}
.dm-crs-dots[hidden]{display:none}
.dm-crs-dots button{width:6px;height:6px;border-radius:var(--dm-r-pill);background:var(--dm-rail);transition:width var(--dm-t) var(--dm-ease),background var(--dm-t-fast)}
@media (hover:hover) and (pointer:fine){.dm-crs-dots button:hover{background:var(--dm-faint)}}
.dm-crs-dots button[aria-current="true"]{width:16px;background:var(--dm-fg2)}
.dm-crs-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
@media (prefers-reduced-motion:reduce){.dm-crs-pv.dm-gen::before{animation:none;opacity:0}:is(.dm-crs-pic,.dm-crs-pv),:is(.dm-crs-pic,.dm-crs-pv)::after,:root:not([data-style="soft"]) :is(.dm-crs-pic,.dm-crs-pv):not(:where([data-style="soft"],[data-style="soft"] *))::after{transition:none}}
Carousel.astroAstro · 43 lines
---
// Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync.
// Carousel: Drafts the agent made to keep one of, or a gallery of pictures, on one swipeable track.
// The markup, CSS and script are designme.dev's own, run by the shared core in ../core, which mounts the component when it scrolls
// into view and plays its demo. To show your own data, change carousel.js. The markup is kept exactly as it is (is:raw).
import type { HTMLAttributes } from 'astro/types';
import '../core/designme.css';
import './carousel.css';

export interface Props extends HTMLAttributes<'div'> {
  /** "soft" draws it in the Soft style. Leave it out for Blueprint, or for the style set on <html> (data-style="soft") */
  'data-style'?: 'soft';
}
const { class: className, ...rest } = Astro.props;
---
<div class:list={['card', className]} data-c="carousel" {...rest}>
  <div class="stage-in"><Fragment is:raw>
<div class="crs dm">
  <div class="dm-main">
    <div class="crs-c panel" data-type="variants" role="region" aria-roledescription="carousel" aria-label="Cover image drafts">
      <div class="crs-h">
        <div class="crs-t"><b>Cover image drafts</b><span class="muted crs-sub">Making 4 variants</span></div>
        <button class="pill line crs-nav crs-prev" aria-label="Previous variant" data-tip="Previous"><svg class="i s"><use href="#i-chev-l"/></svg></button><button class="pill line crs-nav crs-next" aria-label="Next variant" data-tip="Next"><svg class="i s"><use href="#i-chev-r"/></svg></button>
      </div>
      <div class="crs-tr" role="group" tabindex="0" aria-label="Variants. Enter keeps the one in view"></div>
      <div class="crs-dots"></div>
      <p class="crs-sr" aria-live="polite"></p>
    </div>
    <div class="crs-c" data-type="images" role="region" aria-roledescription="carousel" aria-label="Gallery" hidden>
      <div class="crs-h"><button class="pill line crs-nav crs-prev" aria-label="Previous picture" data-tip="Previous"><svg class="i s"><use href="#i-chev-l"/></svg></button><button class="pill line crs-nav crs-next" aria-label="Next picture" data-tip="Next"><svg class="i s"><use href="#i-chev-r"/></svg></button></div>
      <div class="crs-tr" role="group" tabindex="0" aria-label="Pictures"></div>
      <div class="crs-dots"></div>
      <p class="crs-sr" aria-live="polite"></p>
    </div>
  </div>
  <div class="demo"><div class="seg2" role="group" aria-label="Type"><button aria-pressed="true" data-v="variants">Variants</button><button aria-pressed="false" data-v="images">Images</button></div></div>
</div>
  </Fragment></div>
</div>
<script>
  import './carousel.js';
</script>
carousel.htmlHTML · 25 lines
<div class="card" data-c="carousel">
  <div class="stage-in">
    <div class="crs dm">
      <div class="dm-main">
        <div class="crs-c panel" data-type="variants" role="region" aria-roledescription="carousel" aria-label="Cover image drafts">
          <div class="crs-h">
            <div class="crs-t"><b>Cover image drafts</b><span class="muted crs-sub">Making 4 variants</span></div>
            <button class="pill line crs-nav crs-prev" aria-label="Previous variant" data-tip="Previous"><svg class="i s"><use href="#i-chev-l"/></svg></button><button class="pill line crs-nav crs-next" aria-label="Next variant" data-tip="Next"><svg class="i s"><use href="#i-chev-r"/></svg></button>
          </div>
          <div class="crs-tr" role="group" tabindex="0" aria-label="Variants. Enter keeps the one in view"></div>
          <div class="crs-dots"></div>
          <p class="crs-sr" aria-live="polite"></p>
        </div>
        <div class="crs-c" data-type="images" role="region" aria-roledescription="carousel" aria-label="Gallery" hidden>
          <div class="crs-h"><button class="pill line crs-nav crs-prev" aria-label="Previous picture" data-tip="Previous"><svg class="i s"><use href="#i-chev-l"/></svg></button><button class="pill line crs-nav crs-next" aria-label="Next picture" data-tip="Next"><svg class="i s"><use href="#i-chev-r"/></svg></button></div>
          <div class="crs-tr" role="group" tabindex="0" aria-label="Pictures"></div>
          <div class="crs-dots"></div>
          <p class="crs-sr" aria-live="polite"></p>
        </div>
      </div>
      <div class="demo"><div class="seg2" role="group" aria-label="Type"><button aria-pressed="true" data-v="variants">Variants</button><button aria-pressed="false" data-v="images">Images</button></div></div>
    </div>
  </div>
</div>
carousel.cssHTML · 75 lines
/* ---------- Carousel ---------- */
/* one carousel, two types, picked with the switch under it (owner, 4 Nov). Both run on the same native scroll-snap track: a
   swipe, a trackpad, a mouse drag, the arrows, the dots and the keys all move it, and each slide is a framed picture with
   its caption, the next one peeking in. A picture coming into view comes into focus (Soft: out of a blur; Blueprint never
   blurs, so there a veil wipes off it).
   - Variants: the drafts an agent made from one brief, in a panel with its title. Each fills in as it arrives, can fail
     with Retry, and can be kept: Keep turns from an outline to a fill, its frame's edge steps up and the others step back.
   - Images: a gallery of pictures, bare, with Previous and Next above the track.
   --dm-w is a slide's width (80% for images, 84% for variants; 100% shows one at a time, a fixed width mixes sizes), --dm-gap the
   space between slides, --dm-h one height for every picture, and data-align="center" on .crs-c snaps slides to the middle */
.crs-c{width:100%;min-width:0;display:flex;flex-direction:column;gap:var(--dm-s-3)}
.crs-c[hidden]{display:none}
.crs-c.panel{padding:var(--dm-s-4) 0;--dm-w:84%;--dm-gap:var(--dm-s-3)}
.crs-h{display:flex;align-items:center;justify-content:flex-end;gap:var(--dm-s-2)}
.crs-c.panel>.crs-h,.crs-c.panel>.crs-dots{padding:0 var(--dm-s-4)}
.crs-t{flex:1;min-width:0;display:flex;flex-direction:column;font-size:13.5px;line-height:1.4}
.crs-t b{font-weight:500}
.crs-sub{font-size:12px}
.crs-nav{width:32px;padding:0}
@media (hover:hover) and (pointer:fine){.crs-nav:not(:disabled):hover{background:var(--dm-row)}}
.crs-tr{position:relative;display:flex;gap:var(--dm-gap,var(--dm-s-4));overflow-x:auto;overscroll-behavior-x:contain;scroll-snap-type:x mandatory;scrollbar-width:none;border-radius:calc(14px * var(--dm-rk))}
.crs-tr::-webkit-scrollbar{display:none}
.crs-c.panel>.crs-tr{padding:0 var(--dm-s-4);scroll-padding-inline:var(--dm-s-4);border-radius:0}
.crs-c.panel>.crs-tr:focus-visible{outline-offset:-2px;border-radius:var(--dm-r-in)}
.crs-tr.drag{scroll-snap-type:none;cursor:grabbing;user-select:none;-webkit-user-select:none}
.crs-s{flex:0 0 var(--dm-w,80%);min-width:0;display:flex;scroll-snap-align:start;transition:opacity .35s var(--dm-ease)}
.crs-c[data-align="center"] .crs-s{scroll-snap-align:center}
.crs-fig{flex:1;min-width:0;margin:0;display:flex;flex-direction:column;background:var(--dm-surface);border:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent);border-radius:calc(14px * var(--dm-rk));overflow:hidden;transition:border-color var(--dm-t-fast)}
.crs-c.panel .crs-fig{border-radius:var(--dm-r-in)}
/* the picture (or, for a draft, its frame): out of view it is soft and faded, clipped to its frame so the blur never spills
   onto the caption; in view it comes into focus */
.crs-pic,.crs-pv{position:relative;aspect-ratio:var(--dm-ar,3/2);height:var(--dm-h,auto);background-color:var(--dm-row);clip-path:inset(0);transition:filter .7s var(--dm-ease),opacity .7s var(--dm-ease)}
.crs-pic{background-image:var(--dm-art);background-size:cover;background-position:center}
.crs-s:not(.in) :is(.crs-pic,.crs-pv){filter:blur(10px);opacity:.6}
/* Blueprint's veil, the surface over a picture that is out of view, wipes off from the left as the picture comes in */
.crs-pic::after,.crs-pv::after{content:"";position:absolute;inset:0;z-index:1;background:var(--dm-surface);opacity:0;clip-path:inset(0 0 0 100%);pointer-events:none}
:root:not([data-style="soft"]) .crs-s:not(.in) :is(.crs-pic,.crs-pv):not(:where([data-style="soft"],[data-style="soft"] *)){opacity:1}
:root:not([data-style="soft"]) :is(.crs-pic,.crs-pv):not(:where([data-style="soft"],[data-style="soft"] *))::after{opacity:.6;transition:clip-path var(--dm-t-slow) var(--dm-ease)}
:root:not([data-style="soft"]) .crs-s:not(.in) :is(.crs-pic,.crs-pv):not(:where([data-style="soft"],[data-style="soft"] *))::after{clip-path:inset(0)}
/* the caption: the title over a quieter line, as a draft's label sits over its meta, so every slide keeps one height */
.crs-cap{display:flex;flex-direction:column;padding:10px var(--dm-s-3);font-size:13px;line-height:1.35}
.crs-cap b{font-weight:500;color:var(--dm-fg);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.crs-cap .muted{font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
/* a draft is a 3:2 frame that holds its picture once it arrives (owner, 4 Nov). Until then it is a grey frame with an image
   mark; while the agent makes it a light sweeps across, and the picture comes into focus as it lands */
.crs-pv{padding:var(--dm-s-4);display:flex;flex-direction:column;justify-content:center;gap:6px;overflow:hidden}
.crs-pv>.ph{align-self:center;width:24px;height:24px;color:var(--dm-faint)}
.crs-pv.gen::before{content:"";position:absolute;inset:0;background:linear-gradient(100deg,transparent 30%,color-mix(in srgb,var(--dm-surface) 70%,transparent) 50%,transparent 70%) 0 0/250% 100%;animation:crssweep 1.6s var(--dm-ease-io) infinite}
@keyframes crssweep{from{background-position:120% 0}to{background-position:-120% 0}}
.crs-img{position:absolute;inset:0;background-image:var(--dm-art);background-size:cover;background-position:center;animation:crsin .9s var(--dm-ease) both}
/* the picture comes into focus as it arrives; Blueprint never blurs, so there it wipes in from the left */
@keyframes crsin{from{opacity:0;filter:blur(16px);transform:scale(1.06)}to{opacity:1;filter:none;transform:none}}
:root:not([data-style="soft"]) .crs-img:not(:where([data-style="soft"],[data-style="soft"] *)){animation-name:crswipe;animation-duration:var(--dm-t-slow)}
@keyframes crswipe{from{clip-path:inset(0 100% 0 0)}to{clip-path:inset(0)}}
.crs-pv.fail{align-items:center;text-align:center;gap:var(--dm-s-1)}
.crs-pv.fail>.i{color:var(--dm-bad)}
.crs-pv.fail b{font-size:13px;font-weight:500;color:var(--dm-fg)}
.crs-pv.fail .muted{font-size:12px}
.crs-pv.fail .pill{margin-top:6px}
.crs-f{display:flex;align-items:center;gap:var(--dm-s-2);min-height:28px;padding:10px var(--dm-s-3)}
.crs-f>div{flex:1;min-width:0;display:flex;flex-direction:column;font-size:13px;line-height:1.35}
.crs-f b{font-weight:500}
.crs-f .muted{font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.crs-f .tag .spin{width:9px;height:9px}
.crs-keep[aria-pressed="true"]{background:var(--dm-accent);color:var(--dm-on-accent);box-shadow:none}
.crs-c.kept .crs-s:not(.kept){opacity:.45}
.crs-s.kept .crs-fig{border-color:color-mix(in srgb,var(--dm-pick-on) 70%,transparent)}
.crs-dots{display:flex;justify-content:center;gap:6px}
.crs-dots[hidden]{display:none}
.crs-dots button{width:6px;height:6px;border-radius:var(--dm-r-pill);background:var(--dm-rail);transition:width var(--dm-t) var(--dm-ease),background var(--dm-t-fast)}
@media (hover:hover) and (pointer:fine){.crs-dots button:hover{background:var(--dm-faint)}}
.crs-dots button[aria-current="true"]{width:16px;background:var(--dm-fg2)}
.crs-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
@media (prefers-reduced-motion:reduce){.crs-pv.gen::before{animation:none;opacity:0}:is(.crs-pic,.crs-pv),:is(.crs-pic,.crs-pv)::after,:root:not([data-style="soft"]) :is(.crs-pic,.crs-pv):not(:where([data-style="soft"],[data-style="soft"] *))::after{transition:none}}
carousel.jsHTML · 84 lines
DM.def('carousel', (r, c, card) => {
  r.innerHTML = r.innerHTML.replace(/\{chev-l\}/g, I('chev-l')).replace(/\{chev-r\}/g, I('chev-r'));
  const sg = $('.demo .seg2', r), box = { variants:$('.crs-c[data-type="variants"]', r), images:$('.crs-c[data-type="images"]', r) };
  // the pictures: each has words for a screen reader and g, a gradient of its colours that stands in for it (on designme.dev,
  // art/art.js lays the picture of the same name over it)
  const sky = (a, b, c, d, x, y = 50) => `${x == null ? '' : `radial-gradient(circle at ${x}% ${y}%,#fff3d6 0 4%,transparent 24%),`}linear-gradient(${a},${b} 42%,${c} 58%,${d} 60%,color-mix(in srgb,${d} 60%,#000))`;

  /* Variants: four drafts of one cover image, "mountains at golden hour": what each looks like and how long it took. The
     last one fails once */
  const V = [
    { l:'Variant A', d:'Coral sky', t:'9s', art:'cover-a', g:sky('#ff6f61', '#ffb15e', '#ffe6a8', '#c86a68', 70, 52), alt:'Mountains under a coral and amber sky, the sun low on the right' },
    { l:'Variant B', d:'Cool morning', t:'11s', art:'cover-b', g:sky('#2b6cff', '#7fb2ff', '#ffe1c2', '#5a70cc', 28, 52), alt:'Blue mountains under a clear morning sky, the sun rising on the left' },
    { l:'Variant C', d:'Dusk', t:'7s', art:'cover-c', g:sky('#14233f', '#e0577a', '#ffb36b', '#8a3a70', 50, 52), alt:'Dark violet ridges at dusk, a pink sky and the sun setting between them' },
    { l:'Variant D', d:'Mint haze', t:'14s', art:'cover-d', g:sky('#0f8f7f', '#7fe0b8', '#fff2b8', '#3f9f86', 62, 52), alt:'Green mountains in a mint haze, a pale sun above them', fail:1 }
  ];
  const vb = box.variants, vtr = $('.crs-tr', vb), sub = $('.crs-sub', vb), vsr = $('.crs-sr', vb);
  const st = V.map(() => 'wait'); let kept = -1, started = false;
  vtr.innerHTML = V.map((v, i) => `<div class="crs-s" role="group" aria-roledescription="slide" aria-label="${i + 1} of ${V.length}"><figure class="crs-fig"><div class="crs-pv"></div><figcaption class="crs-f"><div><b>${v.l}</b><span class="muted"></span></div><span class="crs-x"></span></figcaption></figure></div>`).join('');
  const S = $$('.crs-s', vtr);
  const say = t => { vsr.textContent = t; };
  function draw(i){
    const v = V[i], s = S[i], pv = $('.crs-pv', s), meta = $('.crs-f .muted', s), x = $('.crs-x', s), k = st[i];
    pv.className = 'crs-pv' + (k === 'fail' ? ' fail' : '');
    pv.classList.toggle('gen', k === 'gen');
    if (k === 'wait' || k === 'gen'){ pv.innerHTML = I('image', 'ph'); meta.textContent = k === 'gen' ? 'Making' : 'Waiting'; x.innerHTML = k === 'gen' ? '<span class="tag"><span class="spin"></span>Making</span>' : '<span class="tag">Queued</span>'; }
    else if (k === 'fail'){ pv.innerHTML = `${I('alert')}<b>Couldn't make this picture</b><span class="muted">The image model timed out</span><button class="pill line sm crs-retry">${I('refresh','xs')}Retry</button>`; meta.textContent = v.d; x.innerHTML = '<span class="tag bad">Failed</span>'; $('.crs-retry', pv).onclick = () => retry(i); }
    else { if (!$('.crs-img', pv)) pv.innerHTML = `<span class="crs-img" role="img" aria-label="${v.alt}" data-pic="${v.art}" style="--dm-art:${v.g}"></span>`; meta.textContent = `${v.d} · ${v.t}`; x.innerHTML = `<button class="pill line sm crs-keep" aria-pressed="${kept === i}">${kept === i ? I('check','xs') + 'Kept' : 'Keep'}</button>`; $('.crs-keep', x).onclick = () => keep(i); }
  }
  function keep(i){ if (st[i] !== 'ready') return; kept = kept === i ? -1 : i; vb.classList.toggle('kept', kept >= 0); S.forEach((s, j) => { s.classList.toggle('kept', j === kept); if (st[j] === 'ready') draw(j); }); swap(sub, kept >= 0 ? `Keeping ${V[kept].l}` : head()); say(kept >= 0 ? `Kept ${V[i].l}` : `${V[i].l} no longer kept`); }
  const head = () => { const done = st.filter(x => x === 'ready').length, bad = st.filter(x => x === 'fail').length, busy = st.some(x => x === 'wait' || x === 'gen'); return busy ? `Making ${V.length} variants, ${done} ready` : `${done} ready${bad ? `, ${bad} failed` : ''}`; };
  // a swipe, a drag, the arrows, the dots and the keys all move the one track; Enter on it keeps the draft in view
  const vk = carousel(c, vtr, { prev:$('.crs-prev', vb), next:$('.crs-next', vb), dots:$('.crs-dots', vb), dot:i => `Go to ${V[i].l}`, say:i => say(`${V[i].l}, ${i + 1} of ${V.length}`),
    key:e => { if (e.key === 'Enter'){ e.preventDefault(); keep(vk.stop.i); } } });
  // the agent makes them one after another, once the drafts are first shown; each comes into focus where its placeholder stood
  async function make(i){ st[i] = 'gen'; draw(i); swap(sub, head()); await c.wait(1100 + i * 150); if (V[i].fail === 1){ V[i].fail = 2; st[i] = 'fail'; draw(i); say(`${V[i].l} failed`); } else { st[i] = 'ready'; draw(i); say(`${V[i].l} ready`); } if (kept < 0) swap(sub, head()); }
  function retry(i){ c.run(async () => { await make(i); }); }
  const startV = () => { if (started) return; started = true; c.run(async () => { await c.wait(500); for (let i = 0; i < V.length; i++) await make(i); }); };
  V.forEach((v, i) => draw(i));

  /* Images: six pictures, each with a title and the colours in it */
  const P = [
    { art:'dusk-dunes', t:'Dunes at dusk', m:'Plum, coral and amber', g:sky('#2a1b5e', '#c2417a', '#ff9a5a', '#d9894f', 68), alt:'Sand dunes under a pink and orange sky, a low sun on the right' },
    { art:'alpine-dawn', t:'Alpine dawn', m:'Periwinkle and peach', g:sky('#3b5bdb', '#9b8cff', '#ffc2a8', '#5a4bc8', 30), alt:'Layers of violet mountains at dawn, the light rising behind them on the left' },
    { art:'aurora-lake', t:'Aurora over the lake', m:'Midnight blue and green', g:sky('#050b2a', '#0d2a5c', '#1f8f7a', '#0a1838'), alt:'Green northern lights over dark hills, mirrored in a still lake under the stars' },
    { art:'coral-sea', t:'Coral sea', m:'Rose, gold and indigo', g:sky('#ff5f8f', '#ff8a6a', '#ffc46b', '#4a3a9a', 42), alt:'The sun setting over a calm sea, a headland on the left and a rose and gold sky' },
    { art:'green-hills', t:'Green hills', m:'Sky blue and leaf green', g:sky('#3fa7ff', '#a8dcff', '#e6f7ff', '#3fb85a'), alt:'Rolling green hills under a blue sky with a few white clouds' },
    { art:'violet-canyon', t:'Violet canyon', m:'Pink, orange and violet', g:sky('#ff7ac6', '#ffb36b', '#ffe08a', '#b23a86', 55), alt:'Layered canyon ridges in pink, red and violet under a glowing orange sky' }
  ];
  const ib = box.images, itr = $('.crs-tr', ib), isr = $('.crs-sr', ib);
  itr.innerHTML = P.map((p, i) => `<div class="crs-s" role="group" aria-roledescription="slide" aria-label="${i + 1} of ${P.length}"><figure class="crs-fig"><div class="crs-pic" role="img" aria-label="${p.alt}" data-pic="${p.art}" style="--dm-art:${p.g}"></div><figcaption class="crs-cap"><b>${p.t}</b><span class="muted">${p.m}</span></figcaption></figure></div>`).join('');
  carousel(c, itr, { prev:$('.crs-prev', ib), next:$('.crs-next', ib), dots:$('.crs-dots', ib), dot:i => `Show ${P[i].t}`, say:i => { isr.textContent = `${P[i].t}, ${i + 1} of ${P.length}`; } });

  /* the switch: Variants or Images. The pick stays with the card, so the demo playing again or Replay keeps it */
  const keepIn = card || {};
  let type = keepIn._crsType === 'images' ? 'images' : 'variants';
  $$('button', sg).forEach(b => b.setAttribute('aria-pressed', b.dataset.v === type));
  const show = (t, fade) => {
    Object.entries(box).forEach(([k, el]) => { el.hidden = k !== t; });
    if (fade && !REDUCE && box[t].animate) box[t].animate([{ opacity:0 }, { opacity:1 }], { duration:260, easing:'ease-out' });
    if (t === 'variants') startV();
  };
  show(type);
  seg(sg, v => { if (v === type) return; type = keepIn._crsType = v; show(v, true); });

  // the preview's story, for the type on show. Variants: once the four are in, flip to the third and keep it. Images: the
  // next two pictures, the last one from the dots, then back one. A switch to the other type ends it, and the next play is
  // the other type's
  if (r === c.st) c.story(async s => {
    const t0 = type, on = () => type === t0, b = box[t0];
    const step = async (el, n) => { if (!on()) throw 'switched'; await s.tap(el); s.away(); await s.beat(n); };
    try {
      if (t0 === 'variants'){
        await s.until(() => !on() || st.every(x => x === 'ready' || x === 'fail')); if (!on()) return; await s.beat(3);
        await step($('.crs-next', b), 2); await step($('.crs-next', b), 2);
        await step($('.crs-keep', S[2]), 5);
      } else {
        await s.beat(4);
        await step($('.crs-next', b), 4); await step($('.crs-next', b), 4);
        await step($$('.crs-dots button', b).pop(), 4); await step($('.crs-prev', b), 4);
      }
    } catch(e){ if (e !== 'switched') throw e; }
  });
});
06

Questions

What people ask about it.

Where do the pictures in the demo come from?
We drew them with a script (art/make-art.py in the repository): gradients, noise and simple shapes, no photos. Installed components draw each one as a gradient of its colours; on designme.dev the drawings are laid over those gradients. Pass your own src for real pictures.
Does it play on its own?
No. Pictures are something to look at and compare, so it only moves when someone moves it.
How do I show one picture at a time?
Set slideWidth="100%". In the HTML version, set --dm-w:100% on the carousel (.crs-c).
How do I retry a failed variant?
Pass onRetry. It gets the variant's key; set that variant's status back to "generating" and then to "ready" or "failed".
Where did Variant carousel and Image carousel go?
They are this component now: Variants is the old Variant carousel and Images the old Image carousel, on the same track. Their old pages lead here.
07

More in Forms and inputs

Buttons, fields, pickers and sign-in flows for any product.

ButtonsEvery button style, with loading, success and hold-to-confirm states.
BadgeSmall labels for plans, environments and status, with a dot, an icon or a remove button.
Progress and meterA run's progress as a ring, steps and a bar, beside a credits meter that warns as it fills.
SliderNamed stops, a stepped scale and a range, with the agent's recommended value marked on the track.
TooltipQuiet labels on hover, with shortcuts and a richer preview card.
Search inputSearch with scoped filters, recent queries and an agent answer.
Country selectA searchable select with country codes, currency and keyboard support.
Multi-selectA combobox that filters as you type and keeps your picks as chips.
All 15 in Forms and inputs →