Forms and inputsCarousel
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 callCarousel
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.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add carouselReact
npx designme add carousel --reactAstro
npx designme add carousel --astroThe 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.
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.
type="variants"ortype="images"; the rest of the props are shared, apart from each type's own.- The track scrolls natively: a swipe, a trackpad, Shift and the mouse wheel all move it, and it snaps to each slide. A mouse can also drag it like a finger, and the click a drag ends with is ignored.
- Slides that can't reach the start of the track (narrow ones near the end) share one resting place, so there is a dot for each place the track can rest and no arrow press or dot ever goes nowhere.
- Slide widths:
slideWidth("80%" for images and "84%" for variants let the next one peek in, "100%" shows one at a time, a number is pixels), and each image'swidthoverrides it, so slides can mix sizes;heightgives every picture one height.align="center"rests each slide in the middle of the track,gapsets the space between slides, andarrows={false}anddots={false}leave a bare swipeable track. - Variants have four states: queued (an empty frame with an image mark and a Queued tag), generating (a light sweeps across the frame, with a Making tag), ready (the picture comes into focus as it lands, with what sets it apart and how long it took) and failed (the reason and a Retry button, with
onRetry). - Keeping a variant fills its button, steps its frame's edge up and dims the others; pressing it again lets it go. The line under the title follows the work: "Making 4 variants, 1 ready", then "3 ready, 1 failed", then "Keeping Variant C".
- Each picture takes
src,altand aplaceholder, a CSS background like a gradient of its colours, drawn under it while it loads or in place of it. A variant'scontentshows anything else instead. - The first slide in view is controlled with
indexandonIndexChange, which fires once the track settles, and the kept variant withkeptandonKeptChange. - With reduced motion the track jumps instead of gliding, and nothing sweeps or blurs in.
- Each type is a region with aria-roledescription="carousel" and a label (the title for variants, "Gallery" by default for images); each slide is a group with aria-roledescription="slide" and its place, like "3 of 6", and holds the picture as an image with its
alt. - The track can take the focus: Left and Right move it one place, Home and End go to the ends, and for variants Enter keeps the one in view.
- The arrows are buttons named after what they move to ("Previous picture", "Next variant"); each dot is a button named after its slide ("Show Coral sea", "Go to Variant C") with aria-current on the current one. Keep has aria-pressed.
- A polite live region says which slide is first in view once the track settles, and for variants when one arrives or fails and which one is kept.
- The keyboard focus ring is the quiet grey ring; a mouse or touch never shows one.
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" />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
| Prop | Type | What it does |
|---|---|---|
index | number | The first slide in view, from 0 |
defaultIndex | number | |
onIndexChange | (index: number) => void | Called with the first slide in view once the track settles somewhere new |
slideWidth | number | string | 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 |
align | 'start' | 'center' | Where a slide rests: at the start of the track (the default) or in its middle |
gap | number | The space between slides, in pixels (16 for images, 12 for variants) |
arrows | boolean | Previous and Next (true) |
dots | boolean | A dot for each place the track can rest, under it (true) |
className | string |
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
| Prop | Type | What it does |
|---|---|---|
typerequired | 'variants' | |
titlerequired | string | Like "Cover image drafts" |
variantsrequired | Variant[] | |
subtitle | string | The line under the title. Defaults to "Making 4 variants, 1 ready", then "3 ready, 1 failed", then "Keeping Variant C" |
kept | string | null | The kept variant's key, or null |
defaultKept | string | null | |
onKeptChange | (key: string | null) => void | |
onRetry | (key: string) => void | Adds 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
| Prop | Type | What it does |
|---|---|---|
typerequired | 'images' | |
imagesrequired | CarouselImage[] | |
label | string | The gallery's name for screen readers. Defaults to "Gallery" |
height | number | One height for every picture, in pixels, for slides of mixed widths. Without it each picture is 3:2 |
Also takes the props of CarouselBaseProps.
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; }
});
});
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
srcfor 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'sstatusback 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.
More in Forms and inputs
Buttons, fields, pickers and sign-in flows for any product.