To make a SwiftUI scroll view fit short content, measure its inner content and set the viewport height to the smaller of that measurement and a chosen limit. Longer content then scrolls within the limit. This walkthrough builds that behavior for a small label panel in your own Mac app.

Why does a short scroll view leave empty space?

Start by separating two dimensions: the height of the content and the height of the viewport displaying it. Apple describes ScrollView as displaying a portion of its underlying content and adjusting the visible portion during scrolling. A short group of labels does not, by itself, establish your preferred viewport height. Apple’s ScrollView documentation explains this distinction.

For a compact desktop panel, define an explicit sizing rule: viewportHeight = min(contentHeight, maximumHeight). Measure the content at the width it actually receives, including any padding that should scroll with it. Apply the result to the outer scroll view. Swift’s min(_:_:) returns the smaller value, while SwiftUI’s frame(width:height:alignment:) establishes a fixed height when its height argument is supplied. Swift min(_:_:), SwiftUI fixed frame.

For illustration, suppose the maximum is 240 points. Content measuring 86 points produces an 86-point viewport; content measuring 410 points produces a 240-point viewport with the remaining content accessible by scrolling. These are illustrative inputs, not measurements from the example below.

Change What it controls Does it establish the desired compact height?
Measured content height, capped and applied to the scroll view Viewport height Yes, in the bounded layout shown below
.scrollBounceBehavior(.basedOnSize) Bounce behavior relative to content size No
.fixedSize(horizontal: false, vertical: true) on the labels The content’s ideal vertical size It supports measurement; the outer viewport still needs its height rule

Apple documents .basedOnSize as a bounce configuration, and fixedSize(horizontal:vertical:) as fixing a view at its ideal size along selected dimensions. Neither description establishes a content-derived viewport height. Scroll bounce behavior, Fixed size by axis.

Build a compact panel with a maximum scroll height

Use a current Xcode installation whose macOS SDK exposes the single-value onGeometryChange(for:of:action:) overload. Apple currently documents that overload as available from macOS 13.0. The complete example below targets macOS 13.3 or later because it also uses scrollBounceBehavior(_:axes:), which starts at macOS 13.3. Geometry change callback, Bounce modifier availability.

Create a macOS app project using SwiftUI. Replace the generated app declaration and content view with this single-file program, leaving only one @main declaration in the target. All labels are synthetic local strings. The Show more labels toggle switches between short and long content.

import SwiftUI

@main
struct CompactLabelsApp: App {
    var body: some Scene {
        WindowGroup {
            CompactLabelsView()
        }
    }
}

struct CompactLabelsView: View {
    @State private var showMore = false
    @State private var viewportHeight: CGFloat = 240

    private var labels: [String] {
        if showMore {
            return (1...18).map { number in
                "Sample label \(number): a longer description that can wrap when the panel becomes narrower."
            }
        }
        return ["Sample label A", "Sample label B"]
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Toggle("Show more labels", isOn: $showMore)

            ScrollView(.vertical) {
                VStack(alignment: .leading, spacing: 8) {
                    if labels.isEmpty {
                        Text("No labels to display")
                    } else {
                        ForEach(labels.indices, id: \.self) { index in
                            Text(labels[index])
                        }
                    }
                }
                .font(.body)
                .fixedSize(horizontal: false, vertical: true)
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding(12)
                .onGeometryChange(for: CGFloat.self) { proxy in
                    min(proxy.size.height.rounded(.up), 240)
                } action: { newHeight in
                    viewportHeight = newHeight
                }
            }
            .frame(height: viewportHeight)
            .scrollBounceBehavior(.basedOnSize, axes: .vertical)
            .border(Color.secondary.opacity(0.4))

            Text("The label panel grows up to 240 points.")
                .font(.caption)
        }
        .padding(20)
        .frame(
            maxWidth: .infinity,
            maxHeight: .infinity,
            alignment: .topLeading
        )
        .frame(minWidth: 300, minHeight: 360)
    }
}

The program has been inspected, but it has not been compiled or run here because this environment has no Swift compiler or macOS runtime. Build it in Xcode and perform the checks below before incorporating it into an existing interface.

Why the measurement belongs inside the scroll view

The geometry callback observes the padded inner stack. Measuring the outer scroll view would report the viewport you already assigned, creating a circular sizing rule rather than discovering the content’s required height.

The transform returns only the rounded, capped height. Apple recommends transforming geometry into the value you need and avoiding broad updates for frequent geometry changes. Here, changes to scroll position do not matter to the calculation, and content taller than the cap continues to produce the same 240-point result. Apple’s geometry change guidance.

Rounding upward avoids deliberately assigning a viewport fractionally shorter than the measured content. The initial 240-point state supplies a usable viewport before measurement arrives; it is a bootstrap value, not a measured content height. The callback subsequently replaces it.

Why width and modifier order matter

Keep horizontal sizing flexible. The example uses .fixedSize(horizontal: false, vertical: true) so the labels can use the available width while retaining their ideal vertical size. Apple warns that fixing an ideal dimension can make a view exceed its parent’s bounds; fixing both dimensions is therefore an unsuitable default for wrapping labels in a narrow panel. Apple’s fixedSize(horizontal:vertical:) documentation.

The 12-point padding comes before the measurement modifier, so it participates in the measured content height. The border comes after the outer height assignment and marks the viewport boundary. This arrangement makes it easier to see whether unused space belongs to the scrolling panel or to its surrounding window.

Verify short content, wrapping, and shrinking

Use this sequence to check the behavior in the example and again after placing the panel in your app’s actual container:

  1. Launch with Show more labels off. The border should surround the two labels and their padding, with the caption immediately below.
  2. Turn Show more labels on. The panel should grow to its 240-point limit. Scroll to confirm that the final label remains reachable.
  3. Narrow and widen the window. Wrapped labels should change height without losing their final lines.
  4. Turn the toggle off again. The panel should shrink back to the short content height.
  5. Temporarily change the short array to []. The explicit No labels to display message should determine the content height.
  6. Repeat with the text styles and surrounding controls used by your real panel.

The empty state is a deliberate interface choice: this example retains a message and padding when the array is empty. If the desired behavior is to remove the panel entirely, conditionally omit the whole scroll view when there are no labels. That is a separate product decision from measuring a nonempty panel.

The surrounding example window retains its minimum height. The code sizes the label viewport; it does not request that the Mac window shrink whenever labels are removed. Any space beneath the caption belongs to the outer layout, outside the bordered scroll view.

Diagnose remaining gaps or clipped labels

If the panel stays tall, first check where the measurement modifier is attached. It should observe the content stack after its internal padding. Then inspect the ancestors for an explicit height or minimum height that reserves space around the panel. Apple documents a supplied frame height as a fixed dimension, so a separate outer frame can retain space even when the inner viewport shrinks. SwiftUI frame sizing.

If the content itself remains unexpectedly tall, remove vertical expansion from the measured subtree. For this small-label pattern, avoid a vertical spacer or a content frame whose minimum height is derived from the viewport. Those choices make the content’s height depend on the viewport, undermining the independent measurement the sizing rule needs.

If the bottom padding is clipped, confirm that padding is included before measurement. If text wraps differently after integration, inspect the actual width offered by the new parent. A height measured at one width should not be treated as a permanent height at another width.

Keep the 240-point cap appropriate to the enclosing interface. The sample supplies a window minimum large enough for its controls, padding, caption, and capped panel. A shorter container needs a smaller budget or a different surrounding layout; this fixed cap does not automatically calculate the space left beneath other controls.

Use this approach for a modest set of eagerly laid-out labels. Apple describes LazyVStack as creating items only when needed for onscreen rendering. Consequently, do not assume that replacing the inner stack with a lazy stack preserves the same complete-content measurement strategy. For a large collection, a stable viewport with lazy content is usually the more suitable design. Apple’s LazyVStack documentation.

When do AppKit and TextKit measurements apply?

For this SwiftUI label panel, measure the rendered SwiftUI subtree. An AppKit text measurement would introduce another layout configuration that must match the displayed labels.

If your app already owns an NSScrollView, retain the same distinction between document and viewport. Apple defines documentView as the view being scrolled, while contentSize is the size of the scroll view’s content view. Despite its name, contentSize is not a measurement of the complete document. AppKit documentView, AppKit contentSize.

For an existing TextKit layout built around NSLayoutManager, usedRect(for:) reports the bounding rectangle of glyphs already laid out in a text container; Apple explicitly says that querying it does not generate glyphs or perform layout. When a complete container layout is required, ensureLayout(for:) is the documented method for forcing that layout first. TextKit usedRect(for:), TextKit ensureLayout(for:).

Even then, the glyph rectangle is not the complete text-view frame. Apple documents additional space around the text container through textContainerInset, as well as line-fragment padding inside it. Account for the actual text view’s configuration rather than copying a glyph height directly into a scrolling viewport. These methods describe the NSLayoutManager path; they are not a universal sizing recipe for every text component. AppKit textContainerInset.

For a small SwiftUI label panel, the practical solution is to keep width flexible, measure the padded inner content, and cap the outer viewport height. Verify the result at both short and long content lengths, then test it inside the real parent layout so that surrounding constraints support the same compact behavior.

Sources