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/docsorapps/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 keyformParams: Record<string, unknown> | null— parameters passed to the active form (e.g.,preselectedService, authmode)openForm(key: string, params?: Record<string, unknown>): void— opens a form popup with optional parameterscloseForm(): void— closes the active popup
Components:
FormDialogProvider— context provider, manages stateuseFormDialog()— hook returning{ openForm, closeForm, activeForm, formParams }FormDialogRenderer— renders the active form inside a Dialog/SheetFormTrigger— convenience component wrapping a button that callsopenForm
Component tree:
1FormDialogProvider (providers.tsx, replaces AuthDialogProvider)2 └─ FormDialogRenderer3 └─ Dialog (shadcn) or Sheet (mobile)4 └─ Active form component (lazy loaded)Form Registry#
A map of form key to configuration:
1type DialogSize = 'tiny' | 'small' | 'medium' | 'large' | 'xlarge' | 'xxlarge' | 'xxxlarge'2type SheetSize = 'content' | 'default' | 'sm' | 'lg' | 'xl' | 'xxl' | 'full'34type FormRegistryEntry = {5 component: ComponentType<{ params?: Record<string, unknown> }>6 title: string7 description?: string8 dialogSize: DialogSize9 sheetSize: SheetSize10 group: 'account' | 'contact' | 'partnerships' | 'quotes'11 /** Optional context panel content for two-panel dialog layout (used by quote forms) */12 contextPanel?: ComponentType13}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:
1const 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-md6 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:
1contact: {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:
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
contextPanelcomponent (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:
Each item calls openForm(key, params) via the useFormDialog() hook.
Page Route Removal & URL Backwards Compatibility#
Routes to Remove#
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:
- Redirects to the homepage (or the most relevant page)
- 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:
1interface FormTriggerProps {2 form: string3 params?: Record<string, unknown>4 children: React.ReactNode5 className?: string6 variant?: ButtonVariant7}89function 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:
1- <Link href="/contact">Contact Us</Link>2+ <FormTrigger form="contact">Contact Us</FormTrigger>34- <Link href="/login">Sign In</Link>5+ <FormTrigger form="auth" params={{ mode: 'signin' }}>Sign In</FormTrigger>67- <Link href="/get-quote/managed-runners">Get Quote</Link>8+ <FormTrigger form="quote-managed-runners">Get Quote</FormTrigger>Files Requiring Link Migration#
apps/www/components/Hero/EnhancedHero.tsxapps/www/components/Hero/Hero.tsxapps/www/components/CTA/EnhancedCTA.tsxapps/www/components/CTABanner/index.tsxapps/www/components/Pricing/ServicePricingSection.tsxapps/www/components/Pricing/ManagedInfraCalculator.tsxapps/www/components/Pricing/PricingCalculator.tsxapps/www/components/Pricing/PricingFAQs.tsxapps/www/components/SocialProof/SocialProofSection.tsxapps/www/app/pricing/PricingContent.tsxapps/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):
Mobile — Sheet Size Mapping#
The Sheet component uses different size variants. The FormDialogRenderer maps between them:
Detection uses a useMediaQuery hook (breakpoint: 768px) to switch between Dialog and Sheet.
Lazy Loading#
All form components are dynamically imported via next/dynamic:
1component: 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)
replaceStateURL 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#
Files to Modify#
Files to Delete#
Testing Strategy#
- Unit tests for
FormDialogProvider— open/close state, params passing, registry lookup - Unit tests for
FormTrigger— click triggersopenFormwith correct params - Integration test for URL query param handling —
?form=contactauto-opens dialog - Integration test for
?form=auth&mode=signupauto-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#
- Create FormDialog system (provider, renderer, registry, trigger)
- Extract
AuthFormContentfrom existingAuthDialog - Extract
ContactFormContentfrom existingContactDialog - Create context panel components for quote forms
- Wire
FormDialogProviderintoproviders.tsx(replacingAuthDialogProvider) - Add header dropdown
- Migrate forms one by one (Contact → Auth → Partner → Assessment → Quotes)
- Replace page links with
FormTriggeracross all ~12 files - Remove old page routes, add redirect stubs
- Remove old Auth dialog system files
- Final accessibility and visual regression testing