Multi-step dialog
canonOne window that walks through steps and reshapes itself between them (width and height, on the canonical spring) instead of closing and opening another window. First case: New deal — search for the person or company first (Attio "Choose record" anatomy, with a persistent footer), then the form-less detail.
27 September 2026: the Attio rework merged (feat/acrm-322-vyber-zaznamu, main 40402b85) — DealSubjectStep gained a persistent footer (↑↓ Navigate + MorphText primary action "Select record"/"Create person"), Records/Actions sections, record type in the meta slot, and paletteWidth(phone). The window reshape moved from the 0.29s cubic-bezier curve to the canonical spring (packages/ui/src/lib/spring.ts, merged as !3957) — see the `spring` and `morph-text` subcomponents. 25 September 2026: the AD had rejected the search step as shipped (palette anatomy without header/footer) — it did not follow the brief (Attio "Choose record"); rework then went to proto/obchod-vyber-attio, now merged as above. AD approved 18 September 2026 on the prototype :3777/preview/obchod-vicekrokovy.html (ACRM-322, ACRM-323). Code: acrm !3777 (<Dialog morphKey>, dialog-morph.css, story + baseline) and !3779 (new-deal flow: DealSubjectStep, Založit osobu → NewContactDialog). Merged 18 September 2026 (!3775, !3777, !3779) — canon. 14 Oct 2026 (owner: the big palette is intentional — „v malém inline dialogu se blbě hledá“): the palette shell (PickPalette, extracted from DealSubjectStep) is the RULES §21 pattern for assigning a record — chat Přiřadit ke kontaktu, Komunikace Přiřadit k projektu, Přidat produkt, Vztahy › Přidat (acrm !5039, 0c0f21ee1). Dev check 14 Oct: 1440 = 800 px, focus in search; 390 = full height 0–844 for the §21 places; Nový obchod on phone 0–844 too (re-measured after deploy). Registry repaired 17 Oct 2026 (review F2): removed classes with no CSS definition and no TSX use on main: .gsp-cancel. Registry repaired 17 Oct 2026 (review F5): DealDialog, DealSubjectStep, NewContactDialog live in apps/web (see files), not exported by @acrm/ui; dropped from react[].
Used in 104 files · 53 areas · 120× — components/CallbackTakeoverModal, components/FilePreview, components/GlobalSearch, components/IdentityImageDialog, components/accounting, components/acl …
One window that walks through several steps and reshapes itself between them instead of closing and opening another window. <Dialog morphKey> (packages/ui/src/components/Dialog.tsx) animates the panel from the previous step's box to the next step's box — width and height together, on the canonical spring (spring, packages/ui/src/lib/spring.ts: damped oscillator, ~750ms, ~6% overshoot) — and apps/web/src/ds/dialog-morph.css fades the new content in. The user sees one object that changes shape, never two windows.
First case, approved by the AD on 18 September 2026 (ACRM-322, ACRM-323): New deal. Without context it starts with a search for the person or company the deal is opened with; the pick reshapes the same window into the form-less deal dialog, prefilled. Prototype: :3777/preview/obchod-vicekrokovy.html. Code: acrm !3777 (morphKey), !3779 (the deal flow).
Variants
- Steps in the same window — the component keeps one
<Dialog>and changes its content, size orbarestate;morphKey= the step. New deal: search (bare, palette widthmin(800px, 94vw)) → detail (size="m", 750). - Steps handed over between dialogs — the next step is its own component with its own
<Dialog>(the canonical New person dialog). Both windows carry amorphKey; the leaving window stores its box and the arriving one, mounted in the same commit, grows out of it with no entrance animation. New deal: search → Create person "…" →NewContactDialog→ detail.
When to use
- The second step depends on a choice made in the first, and the choice is quicker to make as a search than as a field — who the deal is for, which record to merge into.
- The record cannot exist without the first answer (a deal without a person or company is an orphan on the board), so the empty form should never be shown.
- An inline "create it" branch belongs in the flow: the thing you were looking for does not exist yet (Attio Create "…" as record).
When not to use
- Properties that can be set in any order — they are chips or rows of one form-less dialog.
- A wizard of three or more form pages with Back/Next — split the job or rethink it; a step is a different kind of question, not page two of a form.
- Context already answers step one (opened from a person's rail) — skip the step, open the detail straight away.
Anatomy
- Step key —
morphKeyon every<Dialog>of the flow, in the form"<flow>:<step>"(deal:subject,deal:person,deal:detail). A leaving window hands its box over only to a window of the same flow that replaces it in the same React commit — never by timing, never across flows (review acrm !3777, !3779). A key without:animates inside its own window only. No key = today's dialog, nothing animates. - Motion — the window reshapes on the canonical spring (
spring): width and height move together from the box as it was to the box as it will be, on the same damped-oscillator curve as any other reshape in the system, replacing the earlier0.29s cubic-bezier(0.32, 0.72, 0, 1)(RULES.md §11). The target is measured with transitions switched off (the form-less shell has its owntransition: width). An interruption (another step change before the motion settles) cancels the running spring and the next one starts from the box the panel is actually showing at that instant, not from the interrupted target — the window never jumps. After the spring settles, the box is re-measured once more (offsetWidth/offsetHeight) so the next reshape starts from the real, current size rather than from the value that was being animated towards. - Content — the incoming step fades in (
opacity,0.18s, delayed0.1s) while the window is almost at rest; no slide, no scale. The header does not fade — the window's name survives the step. The body does not scroll during the move (overflow: hidden), so no scrollbar flashes in the in-between size. - Handoff between two dialogs — when the step hands over to a different
<Dialog>(search → New person,NewContactDialog), both carry amorphKeyof the same flow ("<flow>:<step>", same<flow>prefix). The leaving dialog stores its box on unmount; the arriving dialog, if it mounts in the same React commit and shares the flow, picks that box up as its own starting point and grows out of it with its entrance animation suppressed — one window handing its shape to the next, not two overlapping animations. The box is claimed by whichever window actually takes it; an unclaimed box is discarded on the next microtask so a stray dialog of a different flow never inherits it. - Search step (new deal,
DealSubjectStep) — the ⌘K palette anatomy per Attio's "Choose record" (Mobbin reference), not the app's own form:<Dialog bare>with no header atwidth={paletteWidth(phoneViewport)}(min(800px, 94vw)desktop,100vwphone); query row.gsp-q(search icon · input ·Esc, phone:.gsp-cancel"Cancel"); body.gsp-bodywith one list — section label.gsp-secreads Recently opened on an empty query or Records while searching (people and companies in one list, not split by type); rows areItemList density="sm"+ItemList.RowwithRowIdentity(inline size, avatar/logo, same as the palette) aslead, the company/domain ascontent(grey, right after the name), and the record's type (Person / Company) asmeta(dimmed text, no border — no icon-only type marker); cursor state rides the canonicalItemListstate="cursor"styling, follows both arrow keys and mouse move, and only re-fades the list when its mode changes (recent → results → actions-only), not on every keystroke;PaletteSkeletonwhile the first fetch is in flight. - Footer — a persistent
.acrm-dialog-foot(not the palette's own.gsp-foot): aleadhint (↑ ↓+ Navigate,aria-hidden— screen readers don't read keyboard legends) and the primary action,<MorphText>inside aButton, reading Select record normally or Create person when the cursor is on the create row — the label morphs on the spring as the cursor moves between the two.kbd="↵"on desktop; on phone the hint is hidden and the button is full width with no kbd hint (the on-screen keyboard makes the shortcut moot). - Create row — with a query and once results have arrived (not under the skeleton), a section Actions holds Create person "…": the palette's command row (
user-plus,create:person). It stays even when something matched — a namesake is not the person being created. - Back — removing the last entity in the header (
LeadEntity→ Remove) returns to the search with the same motion; Cancel in the New person step returns to the search with the query intact. - Phone — every step is the same bottom sheet; the search step additionally goes full screen and is positioned from the visual viewport (
--vv-top/--vv-h,lib/visual-viewport.ts) rather than a plain inset, so the footer CTA stays above the on-screen keyboard instead of sliding underneath it.
Best practices
- Keep the flow in one state owner (the deal dialog holds step, query and the created person), so going back finds what was typed.
- Measure the start box at the moment of the action and the target with transitions off; animate only the change of step, not every render.
- Make the pick in step one the choice it looks like: the picked record is the deal's primary entity, exactly as context from a card would be.
- Honour
prefers-reduced-motion: the step swaps with no transition.
- Don't close one dialog and open another for the next step — that reads as two objects.
- Don't slide or scale the content between steps, and don't give a step its own curve or duration.
- Don't show an empty detail that cannot be submitted (a disabled Create with a reason) when a step can make it impossible instead.
- Don't restyle palette elements for the picker — reuse them; the allowed differences are the
content/metaslots (company or domain, then record type) and the persistent footer, which the full ⌘K palette does not have.
Content
- The search placeholder names what is searched: Search for a person or company….
- The create row quotes the query: Create person "Karel Dvořák". Section labels (Recently opened, Records, Actions) are sentence case, no eyebrow above a menu.
- No explanatory sentence in any step — the structure (search first, detail second) says what to do.
Related
dialog— the shell;morphKeyis its prop.formless-dialog— the detail step and the New person step.command-palette— the anatomy of the search step.spring— the physics the window's reshape and the footer'sMorphTextlabel both run on.morph-text— the footer's primary action label (Select record ↔ Create person).- Rules for transitions between states of one element (chip, button, window): the same spring, content fades in place, shape and type do not change (RULES.md §11).
Source: docs/dialog/multi-step-dialog.usage.md · GitLab