At Atlas I was working on a feature called Atlas AI. Atlas AI is a collection of small assistants, Pick for me, Help me choose, Create a plan, Show my plans, and each one answers a different kind of question. Every sub section collected its own inputs, and every input asked for a different depth of information. Some steps were a single tap on a few chips, some needed a search field with a keyboard, some showed a calendar, and some ended with results dropping onto the map as pins.
All of it had to live on one screen, over a live map, inside a single surface sliding up from the bottom. Each step of each flow wanted that surface to behave a little differently.
I started with the native sheet. UISheetPresentationController gives you a lot for free: the drag, the snap between detents, the dimming behind the surface. For presenting one screen of content it is excellent, and for the first prototype it worked. The problems showed up when one sheet had to carry a whole flow.
The first wall was the dim behind the sheet. Question steps wanted the map dimmed so the user can focus, and the results step wanted the dim gone with the map fully tappable underneath. In the native sheet, dim and interactivity are welded together: an undimmed sheet lets touches through, a dimmed one blocks them, and a tap on the dim dismisses the sheet. There is no way to say dim the background but do not dismiss when tapped, which is exactly what a multi step flow needs. One stray tap and the user loses five steps of answers.
The second wall was how the sheet moves. Every step in the flow wants its own resting height, a small card for chips, taller for a calendar, full height when the keyboard is up. The native sheet can change detents at runtime, but it snaps there as a system decision, separate from the content switching inside. What I wanted was one motion, the height, the content, and the dim all moving together as a step changes. And beyond that, nothing tells you where the sheet is: there is no live height to read, so UI outside the sheet, like a pill pinned above its top edge, has nothing to follow.
I stopped trying to configure the native sheet into something it is not, and built the surface the flow actually needed. I call it a dynamic tray: one surface that stays on screen while the content inside it changes, and the tray reshapes itself around whatever each step needs.
The core idea is a simple inversion of ownership. In the native sheet, behavior belongs to the presentation, decided once when the sheet comes up. In the tray, behavior belongs to the content. Every step of the flow declares what it wants, and the tray follows:
- Its resting heights. A set of detents per step: a fixed height for the results card, a fraction of the screen for the calendar, full height when the keyboard is up, or simply "hug my content" and the tray measures it.
- Its dim. Each step picks the scrim opacity behind the tray. Question steps dim the map for focus, and a tap on that dim does nothing, so a stray touch never throws away the user's answers. The results step sets it to zero and the map underneath becomes fully visible and tappable.
- Its footprint. Touches outside the surface can pass through to the map, or be blocked, per step.
When a step changes, everything responds together. The height springs to the new detent, the old content blurs into the new one, and the dim fades to its new level, all reading as one motion rather than three separate updates.
The same engine also runs in a second mode. A presented tray can be flick dismissed like a normal sheet, while a persistent tray treats its smallest detent as a floor: drag it down and it snaps back instead of going away. And because the tray publishes its live height on every frame, UI outside of it, like a pill riding above its top edge, can track it exactly.
A custom surface has one risk: it can feel almost right. Users have years of muscle memory from Apple's sheets, and anything slightly off reads as cheap immediately.
So the motion is physics, not durations. Every movement is a spring defined by a damping ratio and a response, and the whole tray shares one narrow family of them, so present, resize, and dismiss all feel related. Pull past the tallest detent and it resists like a scroll view edge, stretching less the further you go. Flick it down and dismissal is judged by where the gesture would have landed, not how far it traveled. The dim rides the drag frame by frame, buttons stay instant under the drag recognizer, and the tray grows with the keyboard. None of it is visible in a screenshot. All of it is visible in the first five seconds of touching it.
Rubber Banding
Dim
Flick to Dismiss
The tray is a hybrid. The surface is UIKit, one controller that owns the drag, the springs, and the dim. Everything inside it is hosted SwiftUI. From the outside it feels native: attach a modifier, hand it a view, present it with a binding.
The two layers talk through a small contract. Each step declares its detents, its dim, and its content height through preference keys, and the engine reacts whenever they change. In return, the content reads a dismiss action and the tray's live metrics from the environment.
The motion runs on a display link, stepping real spring physics every frame. Position and height are two independent springs: retarget mid flight and they keep their velocity, grab the tray and the finger takes over the live value. Dragging is a pure transform, so tracking the finger costs no layout at all. A scrollable step hands off naturally, pulling down from the top of its content starts moving the tray itself. And because the tray publishes its height every frame, UI outside it, like a pill pinned above its top edge, follows it exactly.
Every Atlas AI flow now runs inside the tray. Steps morph from a small card to a calendar to a full height search without a single navigation push, the map dims when a question needs focus, and results land as pins you can reach out and touch while the tray sits low. The flow feels like one continuous surface thinking alongside you, which is what progressive disclosure was always supposed to feel like.
The engine itself came out fully content agnostic: nothing inside it knows about the flows it hosts. The same controller now also drives the app's persistent panels.
Dynamic tray — intro
Where should we eat Full flow