Skip to content

Commit 8b7f4da

Browse files
committed
feat(carousel): support auto size
1 parent 9a2b6f1 commit 8b7f4da

File tree

6 files changed

+224
-6
lines changed

6 files changed

+224
-6
lines changed

.changeset/full-facts-wash.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@zag-js/carousel": minor
3+
---
4+
5+
Add support for `autoSize` prop to allow variable width/height slide items.
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import * as carousel from "@zag-js/carousel"
2+
import { normalizeProps, useMachine } from "@zag-js/react"
3+
import { useId } from "react"
4+
import { StateVisualizer } from "../components/state-visualizer"
5+
import { Toolbar } from "../components/toolbar"
6+
7+
// Sample data with variable width content (content-driven sizing)
8+
const variableWidthData = [
9+
{ content: "Short", color: "#ff6b6b", type: "text" },
10+
{
11+
content: "This is a much longer slide with significantly more content that will naturally take up more space",
12+
color: "#4ecdc4",
13+
type: "text",
14+
},
15+
{ content: "Hi", color: "#45b7d1", type: "text" },
16+
{ content: "Medium length slide content here", color: "#96ceb4", type: "text" },
17+
{ content: "⭐ Star Rating Widget ⭐⭐⭐⭐⭐", color: "#ffeaa7", type: "widget" },
18+
{ content: "X", color: "#dda0dd", type: "text" },
19+
{ content: "🔥 Popular Item with Badge 🔥", color: "#98d8c8", type: "badge" },
20+
{ content: "A", color: "#e74c3c", type: "text" },
21+
{ content: "📊 Analytics Dashboard Component 📈📉", color: "#9b59b6", type: "component" },
22+
]
23+
24+
export default function Page() {
25+
const service = useMachine(carousel.machine, {
26+
id: useId(),
27+
autoSize: true,
28+
spacing: "16px",
29+
slideCount: variableWidthData.length,
30+
allowMouseDrag: true,
31+
snapType: "mandatory",
32+
})
33+
34+
const api = carousel.connect(service, normalizeProps)
35+
36+
return (
37+
<>
38+
<main className="carousel-auto-size">
39+
<div {...api.getRootProps()}>
40+
<h2>Auto Size Carousel</h2>
41+
<p>Each slide has a different size based on its content.</p>
42+
43+
<div {...api.getControlProps()}>
44+
<button {...api.getAutoplayTriggerProps()}>{api.isPlaying ? "Stop" : "Play"}</button>
45+
<div className="carousel-spacer" />
46+
<button {...api.getPrevTriggerProps()}>← Prev</button>
47+
<button {...api.getNextTriggerProps()}>Next →</button>
48+
</div>
49+
50+
<div {...api.getItemGroupProps()}>
51+
{variableWidthData.map((slide, index) => {
52+
const itemProps = api.getItemProps({ index, snapAlign: "center" })
53+
return (
54+
<div
55+
{...api.getItemProps({ index })}
56+
key={index}
57+
style={{
58+
...itemProps.style, // Carousel's flex styles first
59+
// Then add our visual styles
60+
minHeight: "120px",
61+
backgroundColor: slide.color,
62+
borderRadius: "8px",
63+
color: "white",
64+
fontWeight: "bold",
65+
textAlign: "center",
66+
padding: "16px",
67+
boxSizing: "border-box",
68+
}}
69+
>
70+
{slide.content}
71+
</div>
72+
)
73+
})}
74+
</div>
75+
76+
<div {...api.getIndicatorGroupProps()}>
77+
{variableWidthData.map((_, index) => (
78+
<button
79+
{...api.getIndicatorProps({ index })}
80+
key={index}
81+
style={{
82+
width: "12px",
83+
height: "12px",
84+
borderRadius: "50%",
85+
border: "none",
86+
backgroundColor: index === api.page ? "#333" : "#ccc",
87+
margin: "0 4px",
88+
cursor: "pointer",
89+
}}
90+
/>
91+
))}
92+
</div>
93+
94+
<div {...api.getProgressTextProps()}>{api.getProgressText()}</div>
95+
</div>
96+
97+
<div style={{ marginTop: "32px" }}>
98+
<h3>Comparison: Fixed Width Carousel</h3>
99+
<FixedWidthExample />
100+
</div>
101+
</main>
102+
103+
<Toolbar>
104+
<StateVisualizer state={service} omit={["translations"]} />
105+
</Toolbar>
106+
107+
<style jsx>{`
108+
.carousel-auto-size {
109+
padding: 32px;
110+
max-width: 800px;
111+
margin: 0 auto;
112+
}
113+
114+
.carousel-spacer {
115+
flex: 1;
116+
}
117+
118+
h2 {
119+
margin-bottom: 8px;
120+
}
121+
122+
p {
123+
margin-bottom: 24px;
124+
color: #666;
125+
}
126+
`}</style>
127+
</>
128+
)
129+
}
130+
131+
function FixedWidthExample() {
132+
const service = useMachine(carousel.machine, {
133+
id: useId(),
134+
autoSize: false,
135+
spacing: "16px",
136+
slidesPerPage: 2,
137+
slideCount: variableWidthData.length,
138+
allowMouseDrag: true,
139+
})
140+
141+
const api = carousel.connect(service, normalizeProps)
142+
143+
return (
144+
<div {...api.getRootProps()}>
145+
<div {...api.getControlProps()}>
146+
<button {...api.getPrevTriggerProps()}>← Prev</button>
147+
<div className="carousel-spacer" />
148+
<button {...api.getNextTriggerProps()}>Next →</button>
149+
</div>
150+
151+
<div {...api.getItemGroupProps()}>
152+
{variableWidthData.map((slide, index) => {
153+
const itemProps = api.getItemProps({ index })
154+
return (
155+
<div
156+
{...itemProps}
157+
key={index}
158+
style={{
159+
...itemProps.style, // Let carousel control layout (grid for fixed width)
160+
// Only add visual styles
161+
minHeight: "120px",
162+
backgroundColor: slide.color,
163+
borderRadius: "8px",
164+
color: "white",
165+
fontWeight: "bold",
166+
textAlign: "center",
167+
padding: "16px",
168+
boxSizing: "border-box",
169+
display: "flex", // Safe to add for content centering in grid items
170+
alignItems: "center",
171+
justifyContent: "center",
172+
}}
173+
>
174+
{slide.content}
175+
</div>
176+
)
177+
})}
178+
</div>
179+
180+
<div {...api.getIndicatorGroupProps()}>
181+
{api.pageSnapPoints.map((_, index) => (
182+
<button
183+
{...api.getIndicatorProps({ index })}
184+
key={index}
185+
style={{
186+
width: "12px",
187+
height: "12px",
188+
borderRadius: "50%",
189+
border: "none",
190+
backgroundColor: index === api.page ? "#333" : "#ccc",
191+
margin: "0 4px",
192+
cursor: "pointer",
193+
}}
194+
/>
195+
))}
196+
</div>
197+
</div>
198+
)
199+
}

packages/machines/carousel/src/carousel.connect.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export function connect<T extends PropTypes>(service: CarouselService, normalize
1414
const canScrollNext = computed("canScrollNext")
1515
const canScrollPrev = computed("canScrollPrev")
1616
const horizontal = computed("isHorizontal")
17+
const autoSize = prop("autoSize")
1718

1819
const pageSnapPoints = Array.from(context.get("pageSnapPoints"))
1920
const page = context.get("page")
@@ -72,8 +73,9 @@ export function connect<T extends PropTypes>(service: CarouselService, normalize
7273
style: {
7374
"--slides-per-page": slidesPerPage,
7475
"--slide-spacing": prop("spacing"),
75-
"--slide-item-size":
76-
"calc(100% / var(--slides-per-page) - var(--slide-spacing) * (var(--slides-per-page) - 1) / var(--slides-per-page))",
76+
"--slide-item-size": autoSize
77+
? "auto"
78+
: "calc(100% / var(--slides-per-page) - var(--slide-spacing) * (var(--slides-per-page) - 1) / var(--slides-per-page))",
7779
},
7880
})
7981
},
@@ -120,13 +122,13 @@ export function connect<T extends PropTypes>(service: CarouselService, normalize
120122
send({ type: "USER.SCROLL" })
121123
},
122124
style: {
123-
display: "grid",
125+
display: autoSize ? "flex" : "grid",
124126
gap: "var(--slide-spacing)",
125127
scrollSnapType: [horizontal ? "x" : "y", prop("snapType")].join(" "),
126128
gridAutoFlow: horizontal ? "column" : "row",
127129
scrollbarWidth: "none",
128130
overscrollBehaviorX: "contain",
129-
[horizontal ? "gridAutoColumns" : "gridAutoRows"]: "var(--slide-item-size)",
131+
[horizontal ? "gridAutoColumns" : "gridAutoRows"]: autoSize ? undefined : "var(--slide-item-size)",
130132
[horizontal ? "scrollPaddingInline" : "scrollPaddingBlock"]: padding,
131133
[horizontal ? "paddingInline" : "paddingBlock"]: padding,
132134
[horizontal ? "overflowX" : "overflowY"]: "auto",
@@ -148,6 +150,8 @@ export function connect<T extends PropTypes>(service: CarouselService, normalize
148150
"aria-label": translations.item(props.index, prop("slideCount")),
149151
"aria-hidden": ariaAttr(!isInView),
150152
style: {
153+
flex: "0 0 auto",
154+
[horizontal ? "maxWidth" : "maxHeight"]: "100%",
151155
scrollSnapAlign: (() => {
152156
const snapAlign = props.snapAlign ?? "start"
153157
const slidesPerMove = prop("slidesPerMove")

packages/machines/carousel/src/carousel.machine.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const machine = createMachine<CarouselSchema>({
2020
autoplay: false,
2121
allowMouseDrag: false,
2222
inViewThreshold: 0.6,
23+
autoSize: false,
2324
...props,
2425
translations: {
2526
nextTrigger: "Next slide",
@@ -57,7 +58,9 @@ export const machine = createMachine<CarouselSchema>({
5758
})),
5859
pageSnapPoints: bindable(() => {
5960
return {
60-
defaultValue: getPageSnapPoints(prop("slideCount"), prop("slidesPerMove"), prop("slidesPerPage")),
61+
defaultValue: prop("autoSize")
62+
? Array.from({ length: prop("slideCount") }, (_, i) => i) // Initialize with slide indices for variable width
63+
: getPageSnapPoints(prop("slideCount"), prop("slidesPerMove"), prop("slidesPerPage")),
6164
}
6265
}),
6366
slidesInView: bindable<number[]>(() => ({
@@ -85,7 +88,7 @@ export const machine = createMachine<CarouselSchema>({
8588
track([() => context.get("page")], () => {
8689
action(["scrollToPage", "focusIndicatorEl"])
8790
})
88-
track([() => prop("orientation")], () => {
91+
track([() => prop("orientation"), () => prop("autoSize")], () => {
8992
action(["setSnapPoints", "scrollToPage"])
9093
})
9194
track([() => prop("slideCount")], () => {

packages/machines/carousel/src/carousel.props.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const props = createProps<CarouselProps>()([
2222
"inViewThreshold",
2323
"translations",
2424
"snapType",
25+
"autoSize",
2526
"onDragStatusChange",
2627
"onAutoplayStatusChange",
2728
])

packages/machines/carousel/src/carousel.types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ export interface CarouselProps extends DirectionProperty, CommonProperties, Orie
6666
* @default 1
6767
*/
6868
slidesPerPage?: number | undefined
69+
/**
70+
* Whether to enable variable width slides.
71+
* @default false
72+
*/
73+
autoSize?: boolean | undefined
6974
/**
7075
* The number of slides to scroll at a time.
7176
*
@@ -153,6 +158,7 @@ type PropsWithDefault =
153158
| "inViewThreshold"
154159
| "translations"
155160
| "slideCount"
161+
| "autoSize"
156162

157163
interface PrivateContext {
158164
pageSnapPoints: number[]

0 commit comments

Comments
 (0)