Carousel
Carousel
Items change size as they move through the container, and their captions adapt with them: the large item shows its title and year, the medium item drops the title, the small item shows neither. Scroll each row, or use the arrow keys, Home, and End.
Multi-browse
Hero
Center-aligned hero
Uncontained
Uncontained multi-aspect ratio








Full-screen
One edge-to-edge item that scrolls vertically, snapping to each in turn.
Showing item 1 of 10.
Show all
On a vertically scrolling page a carousel needs a route to every item that does not involve scrolling sideways. Material asks for a Show all button below it — a composition, not a prop.
Activate an item to see it reported here.
import { useMemo, useState, type ReactNode } from 'react'
import {
Button,
Carousel,
Surface,
Text,
type CarouselItem,
type CarouselLayout,
type MultiAspectCarouselItem,
} from '@language-lit/material3-expressive'
/**
* Local, package-bundled stock photos (Unsplash, via Picsum) rather than a
* hotlinked service: the example still needs no network request at render
* time, so the rendering audit measures the same pixels on every run.
*/
const albums = [
{ title: 'Coastal Static', year: '2019', photo: 'coastal-static' },
{ title: 'Long Exposure', year: '2020', photo: 'long-exposure' },
{ title: 'Harbour Lights', year: '2020', photo: 'harbour-lights' },
{ title: 'Night Ferry', year: '2021', photo: 'night-ferry' },
{ title: 'Second Summer', year: '2021', photo: 'second-summer' },
{ title: 'Low Tide', year: '2022', photo: 'low-tide' },
{ title: 'Signal Hill', year: '2022', photo: 'signal-hill' },
{ title: 'Winter Sessions', year: '2023', photo: 'winter-sessions' },
{ title: 'Open Water', year: '2023', photo: 'open-water' },
{ title: 'Last Train', year: '2024', photo: 'last-train' },
] as const
const ratios = [16 / 9, 1, 9 / 16, 4 / 3, 3 / 4, 16 / 9, 1, 9 / 16] as const
function photoSrc(photo: string) {
return `/images/carousel/${photo}.webp`
}
/**
* The specification's adaptive-content rule: the large item shows its title, the
* medium item hides it, and the small item abbreviates the label. Both spans are
* marked so the stylesheet withdraws them at the right widths. The photo is
* decorative: its subject conveys nothing the caption text does not already say.
*/
function AlbumContent({ title, year, photo }: { title: string; year: string; photo: string }) {
return (
<div className="carousel-example__cover">
<img className="carousel-example__photo" src={photoSrc(photo)} alt="" loading="lazy" />
<div className="carousel-example__caption">
<span data-m3e-carousel-hide="medium">{title}</span>
<span data-m3e-carousel-hide="small">{year}</span>
</div>
</div>
)
}
const albumItems = (onActivate: (title: string) => void): CarouselItem[] =>
albums.map((album) => ({
key: album.title,
label: album.title,
content: <AlbumContent {...album} />,
onActivate: () => onActivate(album.title),
}))
const aspectItems: MultiAspectCarouselItem[] = ratios.map((ratio, index) => ({
key: `ratio-${index}`,
label: `Clip ${index + 1}`,
aspectRatio: ratio,
content: (
<div className="carousel-example__cover">
<img
className="carousel-example__photo"
src={photoSrc(albums[index % albums.length]!.photo)}
alt=""
loading="lazy"
/>
</div>
),
}))
function Row({
id,
label,
description,
children,
}: {
id: CarouselLayout | 'showAll'
label: string
description?: string
children: ReactNode
}) {
return (
<div className="carousel-example__row">
<Text as="h3" variant="titleSmall" id={`carousel-example-${id}`}>
{label}
</Text>
{description === undefined ? null : (
<Text as="p" variant="bodySmall">
{description}
</Text>
)}
{children}
</div>
)
}
export function CarouselExample() {
const [opened, setOpened] = useState<string | null>(null)
const [current, setCurrent] = useState(0)
const items = useMemo(() => albumItems(setOpened), [])
return (
<Surface
as="section"
aria-labelledby="carousel-example-title"
color="surface-container-low"
shape="extra-large"
className="carousel-example"
>
<Text as="h2" id="carousel-example-title" variant="titleLarge" emphasis="emphasized">
Carousel
</Text>
<Text as="p" variant="bodyMedium">
Items change size as they move through the container, and their captions
adapt with them: the large item shows its title and year, the medium item
drops the title, the small item shows neither. Scroll each row, or use the
arrow keys, Home, and End.
</Text>
<Row id="multiBrowse" label="Multi-browse">
<Carousel
aria-labelledby="carousel-example-multiBrowse"
data-example-layout="multiBrowse"
preferredItemWidth={186}
items={items}
/>
</Row>
<Row id="hero" label="Hero">
<Carousel
aria-labelledby="carousel-example-hero"
data-example-layout="hero"
layout="hero"
maxItemWidth={320}
items={items}
/>
</Row>
<Row id="centeredHero" label="Center-aligned hero">
<Carousel
aria-labelledby="carousel-example-centeredHero"
data-example-layout="centeredHero"
layout="centeredHero"
items={items}
/>
</Row>
<Row id="uncontained" label="Uncontained">
<Carousel
aria-labelledby="carousel-example-uncontained"
data-example-layout="uncontained"
layout="uncontained"
itemWidth={220}
items={items}
/>
</Row>
<Row id="multiAspect" label="Uncontained multi-aspect ratio">
<Carousel
aria-labelledby="carousel-example-multiAspect"
data-example-layout="multiAspect"
layout="multiAspect"
items={aspectItems}
/>
</Row>
<Row
id="fullScreen"
label="Full-screen"
description="One edge-to-edge item that scrolls vertically, snapping to each in turn."
>
<Carousel
aria-labelledby="carousel-example-fullScreen"
data-example-layout="fullScreen"
className="carousel-example__full-screen"
layout="fullScreen"
items={items}
currentItem={current}
onCurrentItemChange={setCurrent}
/>
<Text as="p" variant="bodySmall">
Showing item {current + 1} of {items.length}.
</Text>
</Row>
<Row
id="showAll"
label="Show all"
description={
'On a vertically scrolling page a carousel needs a route to every item that ' +
'does not involve scrolling sideways. Material asks for a Show all button ' +
'below it \u2014 a composition, not a prop.'
}
>
<Carousel
aria-labelledby="carousel-example-showAll"
data-example-layout="showAll"
preferredItemWidth={186}
items={items}
/>
<Button variant="text" onClick={() => setOpened('all albums')}>
Show all
</Button>
</Row>
<Text as="p" variant="bodySmall" aria-live="polite">
{opened === null ? 'Activate an item to see it reported here.' : `Opened ${opened}.`}
</Text>
</Surface>
)
}Carouselは、コンテナー内を移動するとサイズが変わる、主に視覚的な項目のスクロール可能な一覧を表示します。Materialが定める6種類のレイアウトを1つのコンポーネントで扱います。
import { Button, Carousel } from '@language-lit/material3-expressive'
import '@language-lit/material3-expressive/styles.css'
// Multi-browse: many items at once, for quick browsing.
<Carousel
aria-label="Recent photos"
preferredItemWidth={186}
items={photos.map((photo) => ({
key: photo.id,
label: photo.title,
content: <img src={photo.src} alt="" />,
onActivate: () => open(photo),
}))}
/>
// Hero: one large item with a preview of what is next.
<Carousel aria-label="Featured" layout="centeredHero" items={featured} />
// Uncontained: same-size items that flow past the edge.
<Carousel aria-label="Articles" layout="uncontained" itemWidth={240} items={articles} />
// Multi-aspect ratio: each item keeps its own shape.
<Carousel
aria-label="Clips"
layout="multiAspect"
items={clips.map((clip) => ({ key: clip.id, content: <video src={clip.src} />, aspectRatio: clip.ratio }))}
/>仕様
項目一覧はitemsで渡します。各項目にはkeyと表示用のcontentを指定し、任意でlabel、onActivate、href、disabledを指定できます。onActivateがある項目は実際のbutton、hrefがある項目は実際のaとして描画します。Space/Enterによるアクティベーションと、フォーカス時にブラウザーが項目をスクロール領域へ表示する動作は、キー処理を独自実装せずプラットフォームに任せます。
layoutで配置方法を選択します。既定値は"multiBrowse"です。
| レイアウト | 用途 | 必須のprop |
|---|---|---|
multiBrowse | 多数の視覚的な項目を一度に見る | preferredItemWidth |
uncontained | テキスト主体、または高度にカスタマイズした項目 | itemWidth |
multiAspect | 形状が実際に異なる項目 | 各項目のaspectRatio |
hero | 非常に大きな項目を目立たせる | — |
centeredHero | 2つのプレビューの中央に同じ項目を配置する | — |
fullScreen | 没入型の縦方向フィード | — |
preferredItemWidthは目標幅であり、保証値ではありません。配置処理はまず小項目、次に中項目を調整し、最後に大項目の幅を調整して、整数個の項目がコンテナーに収まるようにします。minSmallItemWidthとmaxSmallItemWidthで小項目の幅を制限します。既定範囲は仕様に基づく40〜56pxです。
これはレイアウトで使える唯一のレスポンシブ調整手段で、値は利用側が変更します。コンテナーが広がると項目数は増えます。たとえば幅を186に固定すると、狭い幅では3項目、1440pxでは8項目が収まりますが、項目そのものは大きくなりません。Materialのガイドラインでは、ウィンドウ幅600dp未満をcompactとし、その幅では最大3項目を想定します。また、ウィンドウが広がると項目数だけでなく項目も大きくすることを求めています。このアルゴリズムで自動的に得られるのは前者だけなので、後者も必要なら広いブレークポイントでpreferredItemWidthを増やしてください。このpropは数値なので、ウィンドウサイズクラス用のフックやResizeObserverで変更できます。参照すべき規定の拡大率はありません。ファーストパーティのComposeサンプルはすべてのウィンドウ幅で186dpを固定使用しているため、ここでの例も同じ値にしています。
配置に大項目が複数入る場合に起きる動作を知っておくと、問題と誤解せずに済みます。大項目のキ―ラインが複数あるとサイズが同じになるため、項目はサイズを変えずにその間を移動し、末尾側の端で初めてフレームに収まります。これは仕様どおりです。ブラウズ領域は安定し、端でサイズが変わるため、幅の広いmulti-browseは狭い場合と異なるアニメーションになります。
scrollは仕様に記載された2つの動作から選択し、既定ではレイアウトに推奨されるものを使います。2種類のuncontained以外は"snap"です。uncontainedの2種類は"free"になります。full-screenレイアウトではスナップが必須なので、変更しないでください。
currentItem、defaultCurrentItem、onCurrentItemChangeで焦点位置にある項目を管理します。currentItemを設定するとその項目までスクロールし、スクロールすると最寄りの項目が通知されます。itemSpacingで間隔を上書きできます。
Carouselにはアクセシブルな名前が必要なので、aria-labelまたはaria-labelledbyを指定します。divのほかの属性はそのまま渡され、refはスクロールコンテナーを参照します。
項目の適応型コンテンツ
項目の現在幅が通知されるため、再レンダーせずにコンテンツを適応させられます。各項目のdata-m3e-sizeはlarge、medium、smallのいずれかです。data-m3e-carousel-hideを付けたコンテンツは、項目幅がそのコンテンツに必要な幅より狭くなるにつれてフェードアウトします。
<Carousel
aria-label="Albums"
preferredItemWidth={186}
items={albums.map((album) => ({
key: album.id,
label: album.title,
content: (
<>
<img src={album.cover} alt="" />
<figcaption>
<span data-m3e-carousel-hide="medium">{album.title}</span>
<span data-m3e-carousel-hide="small">{album.year}</span>
</figcaption>
</>
),
}))}
/>これはMaterial独自のルールです。大項目にはタイトル全体、中項目ではタイトルを隠し、小項目ではラベルを短縮表示します。参照実装にならって、単純に表示を切り替えるのではなくフェードさせます。タイトルはマスクの端に沿って配置され、項目の幅が不足してくると薄くなります。
属性値は、コンテンツが残る時間を指定するものであり、表示/非表示の境界ではありません。"medium"のコンテンツは焦点位置では不透明で、サイズ範囲の中間までに消えます。"small"は中間位置までに不透明になり、最も狭い表示項目では消えます。そのためタイトルから先に薄くなり、各変化を切り替えではなくフェードとして確認できます。
フェード中のテキストを切り抜かずに定位置へ保つには、参照実装と同様に--m3e-carousel-item-inset-startで移動します。余白の追加より移動を使ってください。余白はコンテンツ自体の幅を変えます。配置アルゴリズムが処理できる項目幅を超えるコンテンツは配置できません。
正確なピクセル幅は--m3e-carousel-item-current-size、--m3e-carousel-item-min-size、--m3e-carousel-item-max-sizeからも取得できます。
これらの値を直接使う場合は注意してください。--m3e-carousel-item-min-sizeはComposeの値をそのまま表し、画面外に少しだけ見える、約10pxのアンカーキ―ラインも含みます。表示される項目の最小幅ではないため、これを使ってサイズを正規化すると、ほとんどすべての項目がmediumに分類されます。サイズ判定には小キ―ラインを使うdata-m3e-sizeを推奨します。
コンテンツは項目に合わせて調整されず、項目によってクリップされます。スナップ位置は項目幅から導かれるため、項目の幅は配置時の幅そのものです。幅が狭くなっても読みやすさを保つ必要があるテキストには、マスクのインセットである--m3e-carousel-item-inset-startと--m3e-carousel-item-inset-endを適用します。そうしない場合、テキストは移動せず、字形の途中で切り取られます。
すべて表示 — アクセシビリティ要件
縦方向にスクロールするページ上の横方向Carouselでは、横スクロール以外の方法でも全項目へ移動できるようにします。MaterialはCarouselの下にすべて表示ボタンを置くか、見出しの横に矢印を置くよう求めています。これはpropではなく、次のように組み合わせます。
<section aria-labelledby="recent-heading">
<h2 id="recent-heading">Recent</h2>
<Carousel aria-labelledby="recent-heading" preferredItemWidth={186} items={photos} />
<Button variant="text" onClick={() => router.push('/photos')}>
Show all
</Button>
</section>full-screenレイアウトではページと同じ軸にスクロールするため、この要件は適用されません。
動作
Carouselは実際のスクロールコンテナーです。ジェスチャー、ホイール、慣性スクロール、キーボードスクロール、RTLへの対応はブラウザーが担います。スナップにはMaterial独自のキ―ラインに合わせたCSS Scroll Snapを使うため、ジェスチャーを離すと配置仕様に従った位置で停止します。フリング距離はブラウザーが決めます。Materialの「1回のフリングで1項目だけ進む」動作に、Web上の相当機能はありません。
項目のマスキングはスクロール位置から計算します。これにより項目が大、中、小の幅に変化し、マスク内の画像に視差効果が生じます。
prefers-reduced-motion: reduceではマスキングを解除します。各項目は最大幅のままとなり、拡大縮小せず、コンテナー端まで届きます。これはMaterialが定めるモーションを減らした場合の表示です。
トークン
Carouselは、トークンが見た目だけでなくレイアウトも変える唯一のコンポーネントです。4つのトークンは配置アルゴリズムに直接使われます。
| トークン | 既定値 | 効果 |
|---|---|---|
--m3e-comp-carousel-min-small-item-size | 40px | 小項目の最小幅 |
--m3e-comp-carousel-max-small-item-size | 56px | 小項目の最大幅 |
--m3e-comp-carousel-anchor-size | 10px | 項目が両端を越えて移動する距離 |
--m3e-comp-carousel-medium-large-item-diff-threshold | 0.85 | medium項目がlarge項目に近づきすぎたと判断する境界 |
そのほかは通常の見た目に関するトークンです。container-color、item-shape、item-container-color、item-content-color、item-spacing、leading-padding、trailing-padding、block-padding、uncontained-trailing-padding、full-screen-padding、full-screen-item-spacing、フォーカスリングの3値、state-layerの色、無効状態の不透明度2種類です。テーマまたはインスタンス単位で上書きできます。
<Carousel
aria-label="Covers"
preferredItemWidth={186}
items={covers}
style={{ '--m3e-comp-carousel-item-shape': '12px' }}
/>レイアウト上の注意
Carouselには高さを指定してください。項目はコンテナーいっぱいに広がり、multi-aspectレイアウトでは高さと項目の比率から幅を求めます。
項目のコンテンツはマスク前の項目サイズでレイアウトされ、クリップされます。画像は項目いっぱいに広げてください。子要素がimg、video、picture、svg、canvasの場合は適切なサイズと切り抜きを適用します。