Services

2026 03 23 Forms In Popups Design


Forms in Popups — Design Spec

Overview#

Move all forms in apps/www from dedicated page routes into popup dialogs. Forms become accessible via a header navigation dropdown and contextual trigger buttons throughout the site. Existing page routes are removed and replaced with redirect stubs that auto-open the corresponding popup.

Goals#

  • Remove dedicated form pages (/login, /signup, /contact, etc.)
  • All forms render inside shadcn Dialog popups
  • Global access via a header nav dropdown
  • Contextual access via in-page trigger buttons
  • Backwards-compatible URL handling for bookmarked links
  • Lazy-loaded form components to minimize bundle impact

Non-Goals#

  • Migrating forms in apps/docs or apps/ui-library (secondary, not in scope)
  • Redesigning form fields or validation logic (forms keep existing internals)
  • Changing form submission API routes

Architecture#

FormDialogProvider#

A React context provider added to the existing provider tree in apps/www/app/providers.tsx (replacing AuthDialogProvider).

State:

  • activeForm: string | null — the currently open form key
  • formParams: Record<string, unknown> | null — parameters passed to the active form (e.g., preselectedService, auth mode)
  • openForm(key: string, params?: Record<string, unknown>): void — opens a form popup with optional parameters
  • closeForm(): void — closes the active popup

Components:

  • FormDialogProvider — context provider, manages state
  • useFormDialog() — hook returning { openForm, closeForm, activeForm, formParams }
  • FormDialogRenderer — renders the active form inside a Dialog/Sheet
  • FormTrigger — convenience component wrapping a button that calls openForm

Component tree:

1
FormDialogProvider (providers.tsx, replaces AuthDialogProvider)
2
└─ FormDialogRenderer
3
└─ Dialog (shadcn) or Sheet (mobile)
4
└─ Active form component (lazy loaded)

Form Registry#

A map of form key to configuration:

ts
1
type DialogSize = 'tiny' | 'small' | 'medium' | 'large' | 'xlarge' | 'xxlarge' | 'xxxlarge'
2
type SheetSize = 'content' | 'default' | 'sm' | 'lg' | 'xl' | 'xxl' | 'full'
3
4
type FormRegistryEntry = {
5
component: ComponentType<{ params?: Record<string, unknown> }>
6
title: string
7
description?: string
8
dialogSize: DialogSize
9
sheetSize: SheetSize
10
group: 'account' | 'contact' | 'partnerships' | 'quotes'
11
/** Optional context panel content for two-panel dialog layout (used by quote forms) */
12
contextPanel?: ComponentType
13
}

Auth forms: The existing AuthDialog already handles sign-in and sign-up with mode switching. Rather than creating separate form components, extract the inner form content from AuthDialog into an AuthFormContent component. Register it once with mode passed via formParams:

ts
1
const formRegistry: Record<string, FormRegistryEntry> = {
2
auth: {
3
component: dynamic(() => import('./forms/AuthFormContent')),
4
title: 'Sign In',
5
dialogSize: 'medium', // matches current AuthDialog sm:max-w-md
6
sheetSize: 'lg',
7
group: 'account',
8
},
9
// Usage: openForm('auth', { mode: 'signin' }) or openForm('auth', { mode: 'signup' })

Contact form: Use the existing ContactDialog inner form content (which includes spam protection, telemetry, preselectedService support), not the simpler ContactForm. Extract the form body from ContactDialog into a ContactFormContent component:

ts
1
contact: {
2
component: dynamic(() => import('./forms/ContactFormContent')),
3
title: 'Contact Us',
4
dialogSize: 'large',
5
sheetSize: 'xl',
6
group: 'contact',
7
},
8
// Usage: openForm('contact', { preselectedService: 'managed-runners' })

Other forms:

ts
1
'request-demo': {
2
component: dynamic(() => import('@/components/Forms/RequestADemoForm')),
3
title: 'Request a Demo',
4
dialogSize: 'xlarge',
5
sheetSize: 'full',
6
group: 'contact',
7
},
8
'request-assessment': {
9
component: dynamic(() => import('@/components/Forms/RequestAssessmentForm')),
10
title: 'Request Assessment',
11
dialogSize: 'xlarge',
12
sheetSize: 'full',
13
group: 'contact',
14
},
15
'become-partner': {
16
component: dynamic(() => import('@/components/Forms/BecomeAPartnerForm')),
17
title: 'Become a Partner',
18
dialogSize: 'large',
19
sheetSize: 'xl',
20
group: 'partnerships',
21
},
22
'quote-managed-runners': {
23
component: dynamic(() => import('@/components/Forms/quotes/ManagedRunnersQuoteForm')),
24
title: 'Managed Runners Quote',
25
dialogSize: 'xxlarge',
26
sheetSize: 'full',
27
group: 'quotes',
28
contextPanel: dynamic(() => import('./forms/context/ManagedRunnersContext')),
29
},
30
'quote-managed-infra': {
31
component: dynamic(() => import('@/components/Forms/quotes/ManagedInfraQuoteForm')),
32
title: 'Managed Infra Quote',
33
dialogSize: 'xxlarge',
34
sheetSize: 'full',
35
group: 'quotes',
36
contextPanel: dynamic(() => import('./forms/context/ManagedInfraContext')),
37
},
38
'quote-devops-training': {
39
component: dynamic(() => import('@/components/Forms/quotes/DevOpsTrainingQuoteForm')),
40
title: 'DevOps Training Quote',
41
dialogSize: 'xxlarge',
42
sheetSize: 'full',
43
group: 'quotes',
44
contextPanel: dynamic(() => import('./forms/context/DevOpsTrainingContext')),
45
},
46
'quote-consulting': {
47
component: dynamic(() => import('@/components/Forms/quotes/ConsultingQuoteForm')),
48
title: 'Consulting Quote',
49
dialogSize: 'xxlarge',
50
sheetSize: 'full',
51
group: 'quotes',
52
contextPanel: dynamic(() => import('./forms/context/ConsultingContext')),
53
},
54
'quote-managed-kubernetes': {
55
component: dynamic(() => import('@/components/Forms/quotes/ManagedKubernetesQuoteForm')),
56
title: 'Managed Kubernetes Quote',
57
dialogSize: 'xxlarge',
58
sheetSize: 'full',
59
group: 'quotes',
60
contextPanel: dynamic(() => import('./forms/context/ManagedKubernetesContext')),
61
},
62
}

Quote Form Context Panels#

Quote forms currently render on a page with a left column containing title, description, and highlight bullets. To preserve this context inside dialogs, the FormDialogRenderer supports an optional two-panel layout for entries with a contextPanel:

  • Left panel: Renders the contextPanel component (title, description, highlights extracted from the current quote page layouts)
  • Right panel: Renders the form component

On mobile (Sheet), the context panel renders above the form as a collapsible section.


Header Navigation Dropdown#

A new dropdown menu in the site header using the existing Radix dropdown menu component.

Label: "Get Started" (or similar, to be decided during implementation)

Groups:

GroupItems
AccountSign In, Sign Up (both open auth form with different mode param)
ContactContact Us, Request a Demo, Request Assessment
PartnershipsBecome a Partner
QuotesManaged Runners, Managed Infra, DevOps Training, Consulting, Managed Kubernetes

Each item calls openForm(key, params) via the useFormDialog() hook.


Page Route Removal & URL Backwards Compatibility#

Routes to Remove#

Current RouteForm KeyNotes
/loginauth (mode: signin)Full page with LoginForm.tsx
/signupauth (mode: signup)Full page with SignUpForm.tsx
/contactcontactFull page with ContactForm.tsx
/request-assessmentrequest-assessmentHas dedicated page route
/get-quote/[service]quote-*Dynamic route with service picker
/(pages)/partners/become-a-partnerbecome-partnerNested route group

Note: /request-demo does not have an existing page route. RequestADemoForm is only accessible via popup (new form, not a migration).

Redirect Strategy#

Each removed route becomes a redirect stub that:

  1. Redirects to the homepage (or the most relevant page)
  2. Appends ?form=<key> query parameter

Examples:

  • /contact/?form=contact
  • /login/?form=auth&mode=signin
  • /get-quote/managed-kubernetes/?form=quote-managed-kubernetes

The /get-quote Index Page#

The /get-quote index page serves as a service picker with descriptions and icons. This discovery UX is valuable. Keep it as a page but replace the "Get Quote" buttons on each service card with FormTrigger components that open the corresponding quote popup. This preserves the browsing/comparison experience.

Query Parameter Handling#

FormDialogProvider reads ?form= and other params from the URL on mount:

  • If the param matches a registered form key, auto-open that form dialog with any additional query params as formParams
  • After opening, clean the query params from the URL (using replaceState) to avoid re-triggering on navigation
  • If the param doesn't match, ignore it silently

Contextual Trigger Buttons#

FormTrigger Component#

A convenience wrapper that replaces existing <Link href="/contact"> patterns:

tsx
1
interface FormTriggerProps {
2
form: string
3
params?: Record<string, unknown>
4
children: React.ReactNode
5
className?: string
6
variant?: ButtonVariant
7
}
8
9
function FormTrigger({ form, params, children, className, variant }: FormTriggerProps) {
10
const { openForm } = useFormDialog()
11
return (
12
<button className={className} onClick={() => openForm(form, params)}>
13
{children}
14
</button>
15
)
16
}

Migration Pattern#

Replace throughout apps/www:

diff
1
- <Link href="/contact">Contact Us</Link>
2
+ <FormTrigger form="contact">Contact Us</FormTrigger>
3
4
- <Link href="/login">Sign In</Link>
5
+ <FormTrigger form="auth" params={{ mode: 'signin' }}>Sign In</FormTrigger>
6
7
- <Link href="/get-quote/managed-runners">Get Quote</Link>
8
+ <FormTrigger form="quote-managed-runners">Get Quote</FormTrigger>
  • apps/www/components/Hero/EnhancedHero.tsx
  • apps/www/components/Hero/Hero.tsx
  • apps/www/components/CTA/EnhancedCTA.tsx
  • apps/www/components/CTABanner/index.tsx
  • apps/www/components/Pricing/ServicePricingSection.tsx
  • apps/www/components/Pricing/ManagedInfraCalculator.tsx
  • apps/www/components/Pricing/PricingCalculator.tsx
  • apps/www/components/Pricing/PricingFAQs.tsx
  • apps/www/components/SocialProof/SocialProofSection.tsx
  • apps/www/app/pricing/PricingContent.tsx
  • apps/www/app/get-quote/page.tsx (keep page, replace quote buttons)
  • Nav/header components

Dialog Sizing & Mobile Behavior#

Desktop — Dialog Size Mapping#

Uses the actual shadcn Dialog size variants (tiny through xxxlarge):

Form TypeDialog SizeApprox Width
Auth (sign in/up)medium (sm:max-w-lg)~512px
Contact, Partnerlarge (md:max-w-xl)~576px
Demo, Assessmentxlarge~672px
Quote forms (two-panel)xxlarge~768px

Mobile — Sheet Size Mapping#

The Sheet component uses different size variants. The FormDialogRenderer maps between them:

Dialog SizeSheet Size
mediumlg
largexl
xlargefull
xxlargefull

Detection uses a useMediaQuery hook (breakpoint: 768px) to switch between Dialog and Sheet.


Lazy Loading#

All form components are dynamically imported via next/dynamic:

ts
1
component: dynamic(() => import('@/components/Forms/ContactFormContent'))
  • Only the active form's code loads when the popup opens
  • A loading skeleton/spinner shows during import
  • No impact on initial page bundle size

Accessibility#

  • Radix Dialog provides native focus trap and focus restoration
  • Ensure lazy loading completes before focus moves into the dialog (show loading state first, then focus the first field once the form mounts)
  • replaceState URL cleanup must not interfere with focus management — run it after the dialog open animation completes
  • All form fields retain existing ARIA labels and error announcements

Files to Create#

FilePurpose
apps/www/components/FormDialog/FormDialogProvider.tsxContext provider + state management
apps/www/components/FormDialog/FormDialogRenderer.tsxRenders active form in Dialog/Sheet with optional two-panel layout
apps/www/components/FormDialog/FormTrigger.tsxConvenience trigger component
apps/www/components/FormDialog/formRegistry.tsForm key → component/config map
apps/www/components/FormDialog/index.tsPublic exports
apps/www/components/FormDialog/forms/AuthFormContent.tsxExtracted from existing AuthDialog
apps/www/components/FormDialog/forms/ContactFormContent.tsxExtracted from existing ContactDialog
apps/www/components/FormDialog/forms/context/*.tsxContext panel components for quote forms

Files to Modify#

FileChange
apps/www/app/providers.tsxReplace AuthDialogProvider with FormDialogProvider
apps/www/components/Nav/* (header)Add "Get Started" dropdown, replace useAuthDialogs() with useFormDialog()
apps/www/app/get-quote/page.tsxKeep page, replace quote buttons with FormTrigger
All 12+ files with form links (see list above)Replace <Link> with <FormTrigger>
Removed page routesConvert to redirect stubs

Files to Delete#

FileReason
apps/www/app/login/LoginForm.tsxReplaced by AuthFormContent in popup
apps/www/app/login/page.tsxRoute removed (becomes redirect stub)
apps/www/app/signup/SignUpForm.tsxReplaced by AuthFormContent in popup
apps/www/app/signup/page.tsxRoute removed
apps/www/app/contact/ (page content)Route removed
apps/www/components/Auth/AuthDialogContext.tsxSuperseded by FormDialogProvider
apps/www/components/Auth/AuthDialog.tsxSuperseded by FormDialogRenderer + AuthFormContent
apps/www/components/Auth/ContactDialog.tsxSuperseded by FormDialogRenderer + ContactFormContent
apps/www/components/Auth/index.tsxNo longer needed
apps/www/app/get-quote/[service]/page.tsxIndividual quote pages removed
apps/www/app/(pages)/partners/become-a-partner/page.tsxRoute removed
apps/www/app/request-assessment/page.tsxRoute removed

Testing Strategy#

  • Unit tests for FormDialogProvider — open/close state, params passing, registry lookup
  • Unit tests for FormTrigger — click triggers openForm with correct params
  • Integration test for URL query param handling — ?form=contact auto-opens dialog
  • Integration test for ?form=auth&mode=signup auto-opens auth in signup mode
  • Visual regression for each form in its dialog at each breakpoint (desktop + mobile)
  • Verify all existing form submission flows still work (API routes unchanged)
  • Verify redirect stubs work for old bookmarked URLs
  • Accessibility: verify focus management with lazy-loaded forms
  • Accessibility: verify focus trap and restoration in Dialog and Sheet modes
  • Mobile: verify Sheet rendering for all form sizes

Migration Order#

  1. Create FormDialog system (provider, renderer, registry, trigger)
  2. Extract AuthFormContent from existing AuthDialog
  3. Extract ContactFormContent from existing ContactDialog
  4. Create context panel components for quote forms
  5. Wire FormDialogProvider into providers.tsx (replacing AuthDialogProvider)
  6. Add header dropdown
  7. Migrate forms one by one (Contact → Auth → Partner → Assessment → Quotes)
  8. Replace page links with FormTrigger across all ~12 files
  9. Remove old page routes, add redirect stubs
  10. Remove old Auth dialog system files
  11. Final accessibility and visual regression testing