skip to content

In Flutter, how do you put a horizontal card carousel inside a vertically scrolling page without layout errors?

level: juniorimportance: must knowfreq 65%

answer

  1. cross axis needs a bound
  2. SizedBox with a fixed height
  3. horizontal ListView.builder stays lazy
  4. PageView when cards should snap
  5. drags split by direction

basics

~20 s

Give the horizontal list a fixed height, usually a SizedBox around a horizontal ListView.builder, and place it as one item of the vertical list. Its cross axis is then bounded, it stays lazy, and horizontal and vertical drags do not conflict.

solid answer

~40 s

A horizontal `ListView` expands to fill its **cross axis**, the height. Inside a vertical list that height is unbounded, so Flutter throws `Horizontal viewport was given unbounded height.` The fix is to bound it: wrap the carousel in `SizedBox(height: 220)` and use `ListView.builder(scrollDirection: Axis.horizontal, ...)`, so cards build lazily as they come into view. The vertical page can be a `ListView` or a `CustomScrollView` with the carousel as one item. Because one scrollable listens for vertical drags and the other for horizontal ones, both scroll without extra code. For card-by-card snapping use a `PageView` with a `PageController(viewportFraction: 0.85)` in the same `SizedBox`. `shrinkWrap` does not help here, because it only sizes the main axis.

code

dart · 33 lines
dart
import 'package:flutter/material.dart';

class LineupPage extends StatelessWidget {
  const LineupPage({super.key, required this.headliners, required this.acts});

  final List<String> headliners;
  final List<String> acts;

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: acts.length + 1,
      itemBuilder: (context, index) {
        if (index == 0) {
          return SizedBox(
            height: 220,
            child: ListView.builder(
              key: const PageStorageKey<String>('headliner-carousel'),
              scrollDirection: Axis.horizontal,
              itemCount: headliners.length,
              itemExtent: 172,
              itemBuilder: (context, i) => Card(
                margin: const EdgeInsets.all(6),
                child: Center(child: Text(headliners[i])),
              ),
            ),
          );
        }
        return ListTile(title: Text(acts[index - 1]));
      },
    );
  }
}

go deeper

for a junior

Remember the pattern: SizedBox with a height around a horizontal ListView.builder, placed as an item of the vertical list.

for a middle

Explain why the cross axis must be bounded, why shrinkWrap does not help, and when PageView replaces the ListView.

for a senior

Keep both lists lazy and cheap with fixed extents and sized images, and avoid intrinsic sizing on long rows.

for a principal

Set a shared carousel component so every team bounds, sizes and snaps horizontal rows the same way.

## The page A festival-lineup page scrolls vertically through the days. Near the top sits a **horizontal carousel** of headliner cards, and below it a long list of acts. The two scroll views run on different axes, and that is what makes this nesting easy — as long as the carousel's height is bounded. ## Why it fails without a height Every scroll view contains a **viewport**. A viewport fills its container in the **scrolling direction** and **stretches across the cross axis** to fill it as well. For a horizontal `ListView` the cross axis is vertical. When that list is a child of a vertical `ListView`, the parent offers its children unlimited height (that is how a vertical list can be taller than the screen). The horizontal viewport is asked to fill infinite height and fails with: `Horizontal viewport was given unbounded height.` ## The fix 1. Decide the carousel's height from the card design, for example 220 logical pixels. 2. Wrap the carousel in `SizedBox(height: 220)`. 3. Use `ListView.builder(scrollDirection: Axis.horizontal, itemCount: ..., itemBuilder: ...)` so only visible cards are built. 4. Give each card a width (`SizedBox(width: 160)` or an `AspectRatio`); a horizontal list gives its children unbounded width. 5. Put the whole thing in the outer list as one item, or in a `SliverToBoxAdapter` if the page is a `CustomScrollView`. ## Choosing the carousel widget | Widget | Behaviour | Fits | |---|---|---| | `ListView.builder` (horizontal) | free scrolling, lazy | many small cards, 'see all' rows | | `PageView.builder` with `PageController(viewportFraction: 0.85)` | snaps one card at a time, neighbours peek | featured headliners, onboarding | | Material `CarouselView` (Flutter 3.24+) | Material 3 carousel with a required `itemExtent` | Material-styled galleries | ## Why the drags do not fight The outer list reacts to vertical drags and the carousel to horizontal ones. When a finger moves, the gesture system lets the recognizer that matches the dominant direction win, so a mostly-horizontal swipe moves the carousel and a mostly-vertical one scrolls the page. No `NeverScrollableScrollPhysics` or custom coordination is needed for **cross-axis** nesting; that problem only appears when both scroll views share an axis. ## Things that look like fixes but are not - **`shrinkWrap: true` on the carousel** — shrink-wrapping sizes the **main** axis (width) to the content. The unbounded **height** remains, and the assertion still fires because a shrink-wrapping viewport also requires a bounded cross axis. - **`IntrinsicHeight` to size to the tallest card** — this asks every card for its intrinsic height, which defeats lazy building and is expensive for long rows. - **`Expanded` inside the vertical list** — a vertical list is not a flex layout with a bounded height to divide; it has nothing to expand into. ## Keeping it smooth - Fix the card width with `itemExtent` or a fixed-size child where possible, so the horizontal list can compute visible indexes without measuring. - Give the carousel a `PageStorageKey` if its position should survive scrolling it out of view and back. - Cache card images at the displayed size; a carousel of full-resolution posters is a common source of memory spikes.

  • Why does shrinkWrap: true not fix the carousel's height error?
    `shrinkWrap` makes a scroll view size its **main** axis to its content. A horizontal list's main axis is width; the problem is the unbounded **height**, its cross axis. A shrink-wrapping viewport still requires a bounded cross axis, so the assertion remains. Bound the height with a `SizedBox`.
  • When would you choose PageView over a horizontal ListView for the carousel?
    When each swipe should settle on exactly one card. `PageView` snaps by default (`pageSnapping: true`), and a `PageController` with `viewportFraction` below 1.0 lets neighbours peek in. A horizontal `ListView` scrolls freely and suits rows of many small cards.

saying these in an interview costs you the question

  • shrinkWrap: true fixes the carousel's unbounded height.
  • Nested horizontal and vertical lists need NeverScrollableScrollPhysics.
  • Wrap the carousel in Expanded inside the vertical list.
  • A horizontal ListView builds all cards up front.
  • IntrinsicHeight is a cheap way to size a long carousel.