about summary refs log tree commit diff
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2024-01-18 20:28:04 -0600
committerGitHub <noreply@github.com>2024-01-18 20:28:04 -0600
commit66b8774ecb9c5d465987909577ddad3dd4a3ab8e (patch)
treeb1874c6cedd0111eca41db237e606f8e50739d55
parent9cbd3c0937d22e8dccbd9c086d3a3a24dbd27b3a (diff)
downloadvoidsky-66b8774ecb9c5d465987909577ddad3dd4a3ab8e.tar.zst
New component library based on ALF (#2459)
* Install on native as well

* Add button and link components

* Comments

* Use new prop

* Add some form elements

* Add labels to input

* Fix line height, add suffix

* Date inputs

* Autofill styles

* Clean up InputDate types

* Improve types for InputText, value handling

* Enforce a11y props on buttons

* Add Dialog, Portal

* Dialog contents

* Native dialog

* Clean up

* Fix animations

* Improvements to web modal, exiting still broken

* Clean up dialog types

* Add Prompt, Dialog refinement, mobile refinement

* Integrate new design tokens, reorg storybook

* Button colors

* Dim mode

* Reorg

* Some styles

* Toggles

* Improve a11y

* Autosize dialog, handle max height, Dialog.ScrolLView not working

* Try to use BottomSheet's own APIs

* Scrollable dialogs

* Add web shadow

* Handle overscroll

* Styles

* Dialog text input

* Shadows

* Button focus states

* Button pressed states

* Gradient poc

* Gradient colors and hovers

* Add hrefAttrs to Link

* Some more a11y

* Toggle invalid states

* Update dialog descriptions for demo

* Icons

* WIP Toggle cleanup

* Refactor toggle to not rely on immediate children

* Make Toggle controlled

* Clean up Toggles storybook

* ToggleButton styles

* Improve a11y labels

* ToggleButton hover darkmode

* Some i18n

* Refactor input

* Allow extension of input

* Remove old input

* Improve icons, add CalendarDays

* Refactor DateField, web done

* Add label example

* Clean up old InputDate, DateField android, text area example

* Consistent imports

* Button context, icons

* Add todo

* Add closeAllDialogs control

* Alignment

* Expand color palette

* Hitslops, add shortcut to Storybook in dev

* Fix multiline on ios

* Mark dialog close button as unused
-rw-r--r--assets/icons/arrowTopRight_stoke2_corner0_rounded.svg1
-rw-r--r--assets/icons/calendarDays_stroke2_corner0_rounded.svg1
-rw-r--r--assets/icons/colorPalette_stroke2_corner0_rounded.svg1
-rw-r--r--assets/icons/globe_stroke2_corner0_rounded.svg1
-rw-r--r--bskyweb/templates/base.html19
-rw-r--r--package.json1
-rw-r--r--src/App.native.tsx59
-rw-r--r--src/App.web.tsx18
-rw-r--r--src/Navigation.tsx6
-rw-r--r--src/alf/atoms.ts344
-rw-r--r--src/alf/index.tsx1
-rw-r--r--src/alf/themes.ts300
-rw-r--r--src/alf/tokens.ts174
-rw-r--r--src/alf/util/flatten.ts3
-rw-r--r--src/components/Button.tsx507
-rw-r--r--src/components/Dialog/context.ts35
-rw-r--r--src/components/Dialog/index.tsx162
-rw-r--r--src/components/Dialog/index.web.tsx194
-rw-r--r--src/components/Dialog/types.ts43
-rw-r--r--src/components/Link.tsx191
-rw-r--r--src/components/Portal.tsx56
-rw-r--r--src/components/Prompt.tsx119
-rw-r--r--src/components/Typography.tsx (renamed from src/view/com/Typography.tsx)34
-rw-r--r--src/components/forms/DateField/index.android.tsx108
-rw-r--r--src/components/forms/DateField/index.tsx56
-rw-r--r--src/components/forms/DateField/index.web.tsx64
-rw-r--r--src/components/forms/DateField/types.ts7
-rw-r--r--src/components/forms/DateField/utils.ts16
-rw-r--r--src/components/forms/InputGroup.tsx43
-rw-r--r--src/components/forms/TextField.tsx334
-rw-r--r--src/components/forms/Toggle.tsx473
-rw-r--r--src/components/forms/ToggleButton.tsx124
-rw-r--r--src/components/hooks/useInteractionState.ts21
-rw-r--r--src/components/icons/ArrowTopRight.tsx5
-rw-r--r--src/components/icons/CalendarDays.tsx5
-rw-r--r--src/components/icons/ColorPalette.tsx5
-rw-r--r--src/components/icons/Globe.tsx5
-rw-r--r--src/components/icons/TEMPLATE.tsx48
-rw-r--r--src/components/icons/common.ts32
-rw-r--r--src/state/dialogs/index.tsx44
-rw-r--r--src/view/com/Button.tsx204
-rw-r--r--src/view/com/pager/FeedsTabBarMobile.tsx23
-rw-r--r--src/view/icons/Logo.tsx9
-rw-r--r--src/view/screens/DebugNew.tsx541
-rw-r--r--src/view/screens/Storybook/Breakpoints.tsx25
-rw-r--r--src/view/screens/Storybook/Buttons.tsx124
-rw-r--r--src/view/screens/Storybook/Dialogs.tsx90
-rw-r--r--src/view/screens/Storybook/Forms.tsx215
-rw-r--r--src/view/screens/Storybook/Icons.tsx41
-rw-r--r--src/view/screens/Storybook/Links.tsx48
-rw-r--r--src/view/screens/Storybook/Palette.tsx336
-rw-r--r--src/view/screens/Storybook/Shadows.tsx53
-rw-r--r--src/view/screens/Storybook/Spacing.tsx64
-rw-r--r--src/view/screens/Storybook/Theming.tsx56
-rw-r--r--src/view/screens/Storybook/Typography.tsx30
-rw-r--r--src/view/screens/Storybook/index.tsx78
-rw-r--r--src/view/shell/index.tsx2
-rw-r--r--src/view/shell/index.web.tsx2
-rw-r--r--web/index.html19
-rw-r--r--yarn.lock25
60 files changed, 4680 insertions, 965 deletions
diff --git a/assets/icons/arrowTopRight_stoke2_corner0_rounded.svg b/assets/icons/arrowTopRight_stoke2_corner0_rounded.svg
new file mode 100644
index 000000000..554a7374e
--- /dev/null
+++ b/assets/icons/arrowTopRight_stoke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M8 6a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v9a1 1 0 1 1-2 0V8.414l-9.793 9.793a1 1 0 0 1-1.414-1.414L15.586 7H9a1 1 0 0 1-1-1Z" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/calendarDays_stroke2_corner0_rounded.svg b/assets/icons/calendarDays_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..09d9c0f49
--- /dev/null
+++ b/assets/icons/calendarDays_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4 3a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H4Zm1 16V9h14v10H5ZM5 7h14V5H5v2Zm3 10.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5ZM17.25 12a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0ZM12 13.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5ZM9.25 12a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0ZM12 17.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/colorPalette_stroke2_corner0_rounded.svg b/assets/icons/colorPalette_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..b1056e1a9
--- /dev/null
+++ b/assets/icons/colorPalette_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4 12c0-4.09 3.527-7.5 8-7.5s8 3.41 8 7.5c0 1.579-.419 2.056-.708 2.236-.388.241-1.031.286-2.058.153-.33-.043-.652-.096-.991-.152a65.905 65.905 0 0 0-.531-.087c-.52-.081-1.077-.156-1.61-.164-1.065-.016-2.336.245-2.996 1.567-.418.834-.295 1.67-.078 2.314.18.534.47 1.055.683 1.437v.001l.097.175.01.018C7.432 19.407 4 16.033 4 12Zm8-9.5C6.532 2.5 2 6.7 2 12s4.532 9.5 10 9.5c.401 0 .812-.04 1.166-.193.41-.176.761-.517.866-1.028.085-.416-.03-.796-.118-1.029a5.981 5.981 0 0 0-.351-.73l-.12-.215c-.215-.392-.403-.73-.52-1.078-.13-.387-.111-.614-.029-.78.146-.291.404-.473 1.178-.461.385.005.825.06 1.329.14.15.023.308.05.47.077.36.059.742.122 1.105.17 1.021.132 2.325.213 3.373-.439C21.496 15.22 22 13.874 22 12c0-5.3-4.532-9.5-10-9.5Zm3.5 8.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM9 12.25a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm1.5-2.75a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/globe_stroke2_corner0_rounded.svg b/assets/icons/globe_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..83cb88d13
--- /dev/null
+++ b/assets/icons/globe_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4.062 11h2.961c.103-2.204.545-4.218 1.235-5.77.06-.136.123-.269.188-.399A8.007 8.007 0 0 0 4.062 11ZM12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm0 2c-.227 0-.518.1-.868.432-.354.337-.719.872-1.047 1.61-.561 1.263-.958 2.991-1.06 4.958h5.95c-.102-1.967-.499-3.695-1.06-4.958-.328-.738-.693-1.273-1.047-1.61C12.518 4.099 12.227 4 12 4Zm4.977 7c-.103-2.204-.545-4.218-1.235-5.77a9.78 9.78 0 0 0-.188-.399A8.006 8.006 0 0 1 19.938 11h-2.961Zm-2.003 2H9.026c.101 1.966.498 3.695 1.06 4.958.327.738.692 1.273 1.046 1.61.35.333.641.432.868.432.227 0 .518-.1.868-.432.354-.337.719-.872 1.047-1.61.561-1.263.958-2.991 1.06-4.958Zm.58 6.169c.065-.13.128-.263.188-.399.69-1.552 1.132-3.566 1.235-5.77h2.961a8.006 8.006 0 0 1-4.384 6.169Zm-7.108 0a9.877 9.877 0 0 1-.188-.399c-.69-1.552-1.132-3.566-1.235-5.77H4.062a8.006 8.006 0 0 0 4.384 6.169Z" clip-rule="evenodd"/></svg>
diff --git a/bskyweb/templates/base.html b/bskyweb/templates/base.html
index 57ad064f8..942e18fcc 100644
--- a/bskyweb/templates/base.html
+++ b/bskyweb/templates/base.html
@@ -39,6 +39,25 @@
       height: calc(100% + env(safe-area-inset-top));
     }
 
+    /* Remove autofill styles on Webkit */
+    input:-webkit-autofill,
+    input:-webkit-autofill:hover, 
+    input:-webkit-autofill:focus,
+    textarea:-webkit-autofill,
+    textarea:-webkit-autofill:hover,
+    textarea:-webkit-autofill:focus,
+    select:-webkit-autofill,
+    select:-webkit-autofill:hover,
+    select:-webkit-autofill:focus {
+      border: 0;
+      -webkit-text-fill-color: transparent;
+      -webkit-box-shadow: none;
+    }
+    /* Force left-align date/time inputs on iOS mobile */
+    input::-webkit-date-and-time-value {
+      text-align: left;
+    }
+
     /* Color theming */
     :root {
       --text: black;
diff --git a/package.json b/package.json
index 517c328c1..ff0ddd3e9 100644
--- a/package.json
+++ b/package.json
@@ -70,6 +70,7 @@
     "@segment/analytics-react-native": "^2.10.1",
     "@segment/sovran-react-native": "^0.4.5",
     "@sentry/react-native": "5.5.0",
+    "@tamagui/focus-scope": "^1.84.1",
     "@tanstack/react-query": "^5.8.1",
     "@tiptap/core": "^2.0.0-beta.220",
     "@tiptap/extension-document": "^2.0.0-beta.220",
diff --git a/src/App.native.tsx b/src/App.native.tsx
index 9de901767..41b78fc98 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -13,6 +13,8 @@ import {
 
 import 'view/icons'
 
+import {ThemeProvider as Alf} from '#/alf'
+import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
 import {init as initPersistedState} from '#/state/persisted'
 import {listenSessionDropped} from './state/events'
 import {useColorMode} from 'state/shell'
@@ -25,6 +27,7 @@ import {queryClient} from 'lib/react-query'
 import {TestCtrls} from 'view/com/testing/TestCtrls'
 import {Provider as ShellStateProvider} from 'state/shell'
 import {Provider as ModalStateProvider} from 'state/modals'
+import {Provider as DialogStateProvider} from 'state/dialogs'
 import {Provider as LightboxStateProvider} from 'state/lightbox'
 import {Provider as MutedThreadsProvider} from 'state/muted-threads'
 import {Provider as InvitesStateProvider} from 'state/invites'
@@ -39,6 +42,7 @@ import {
 import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
 import * as persisted from '#/state/persisted'
 import {Splash} from '#/Splash'
+import {Provider as PortalProvider} from '#/components/Portal'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
@@ -48,6 +52,7 @@ function InnerApp() {
   const colorMode = useColorMode()
   const {isInitialLoad, currentAccount} = useSession()
   const {resumeSession} = useSessionApi()
+  const theme = useColorModeTheme(colorMode)
   const {_} = useLingui()
 
   // init
@@ -63,25 +68,27 @@ function InnerApp() {
 
   return (
     <SafeAreaProvider initialMetrics={initialWindowMetrics}>
-      <Splash isReady={!isInitialLoad}>
-        <React.Fragment
-          // Resets the entire tree below when it changes:
-          key={currentAccount?.did}>
-          <LoggedOutViewProvider>
-            <UnreadNotifsProvider>
-              <ThemeProvider theme={colorMode}>
-                {/* All components should be within this provider */}
-                <RootSiblingParent>
-                  <GestureHandlerRootView style={s.h100pct}>
-                    <TestCtrls />
-                    <Shell />
-                  </GestureHandlerRootView>
-                </RootSiblingParent>
-              </ThemeProvider>
-            </UnreadNotifsProvider>
-          </LoggedOutViewProvider>
-        </React.Fragment>
-      </Splash>
+      <Alf theme={theme}>
+        <Splash isReady={!isInitialLoad}>
+          <React.Fragment
+            // Resets the entire tree below when it changes:
+            key={currentAccount?.did}>
+            <LoggedOutViewProvider>
+              <UnreadNotifsProvider>
+                <ThemeProvider theme={colorMode}>
+                  {/* All components should be within this provider */}
+                  <RootSiblingParent>
+                    <GestureHandlerRootView style={s.h100pct}>
+                      <TestCtrls />
+                      <Shell />
+                    </GestureHandlerRootView>
+                  </RootSiblingParent>
+                </ThemeProvider>
+              </UnreadNotifsProvider>
+            </LoggedOutViewProvider>
+          </React.Fragment>
+        </Splash>
+      </Alf>
     </SafeAreaProvider>
   )
 }
@@ -109,11 +116,15 @@ function App() {
             <MutedThreadsProvider>
               <InvitesStateProvider>
                 <ModalStateProvider>
-                  <LightboxStateProvider>
-                    <I18nProvider>
-                      <InnerApp />
-                    </I18nProvider>
-                  </LightboxStateProvider>
+                  <DialogStateProvider>
+                    <LightboxStateProvider>
+                      <I18nProvider>
+                        <PortalProvider>
+                          <InnerApp />
+                        </PortalProvider>
+                      </I18nProvider>
+                    </LightboxStateProvider>
+                  </DialogStateProvider>
                 </ModalStateProvider>
               </InvitesStateProvider>
             </MutedThreadsProvider>
diff --git a/src/App.web.tsx b/src/App.web.tsx
index 1bdb3c208..1efa0567c 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -8,6 +8,7 @@ import {RootSiblingParent} from 'react-native-root-siblings'
 import 'view/icons'
 
 import {ThemeProvider as Alf} from '#/alf'
+import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
 import {init as initPersistedState} from '#/state/persisted'
 import {useColorMode} from 'state/shell'
 import {Shell} from 'view/shell/index'
@@ -16,6 +17,7 @@ import {ThemeProvider} from 'lib/ThemeContext'
 import {queryClient} from 'lib/react-query'
 import {Provider as ShellStateProvider} from 'state/shell'
 import {Provider as ModalStateProvider} from 'state/modals'
+import {Provider as DialogStateProvider} from 'state/dialogs'
 import {Provider as LightboxStateProvider} from 'state/lightbox'
 import {Provider as MutedThreadsProvider} from 'state/muted-threads'
 import {Provider as InvitesStateProvider} from 'state/invites'
@@ -29,7 +31,7 @@ import {
 } from 'state/session'
 import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
 import * as persisted from '#/state/persisted'
-import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
+import {Provider as PortalProvider} from '#/components/Portal'
 
 function InnerApp() {
   const {isInitialLoad, currentAccount} = useSession()
@@ -92,11 +94,15 @@ function App() {
             <MutedThreadsProvider>
               <InvitesStateProvider>
                 <ModalStateProvider>
-                  <LightboxStateProvider>
-                    <I18nProvider>
-                      <InnerApp />
-                    </I18nProvider>
-                  </LightboxStateProvider>
+                  <DialogStateProvider>
+                    <LightboxStateProvider>
+                      <I18nProvider>
+                        <PortalProvider>
+                          <InnerApp />
+                        </PortalProvider>
+                      </I18nProvider>
+                    </LightboxStateProvider>
+                  </DialogStateProvider>
                 </ModalStateProvider>
               </InvitesStateProvider>
             </MutedThreadsProvider>
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index c68cb0580..ea0cd384e 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -61,7 +61,7 @@ import {ProfileListScreen} from './view/screens/ProfileList'
 import {PostThreadScreen} from './view/screens/PostThread'
 import {PostLikedByScreen} from './view/screens/PostLikedBy'
 import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
-import {DebugScreen} from './view/screens/DebugNew'
+import {Storybook} from './view/screens/Storybook'
 import {LogScreen} from './view/screens/Log'
 import {SupportScreen} from './view/screens/Support'
 import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy'
@@ -200,8 +200,8 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
       />
       <Stack.Screen
         name="Debug"
-        getComponent={() => DebugScreen}
-        options={{title: title(msg`Debug`), requireAuth: true}}
+        getComponent={() => Storybook}
+        options={{title: title(msg`Storybook`), requireAuth: true}}
       />
       <Stack.Screen
         name="Log"
diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts
index c142f5f71..203c2f282 100644
--- a/src/alf/atoms.ts
+++ b/src/alf/atoms.ts
@@ -4,6 +4,9 @@ export const atoms = {
   /*
    * Positioning
    */
+  fixed: {
+    position: 'fixed',
+  },
   absolute: {
     position: 'absolute',
   },
@@ -32,6 +35,10 @@ export const atoms = {
     zIndex: 50,
   },
 
+  overflow_hidden: {
+    overflow: 'hidden',
+  },
+
   /*
    * Width
    */
@@ -45,6 +52,12 @@ export const atoms = {
   /*
    * Border radius
    */
+  rounded_2xs: {
+    borderRadius: tokens.borderRadius._2xs,
+  },
+  rounded_xs: {
+    borderRadius: tokens.borderRadius.xs,
+  },
   rounded_sm: {
     borderRadius: tokens.borderRadius.sm,
   },
@@ -58,8 +71,8 @@ export const atoms = {
   /*
    * Flex
    */
-  gap_xxs: {
-    gap: tokens.space.xxs,
+  gap_2xs: {
+    gap: tokens.space._2xs,
   },
   gap_xs: {
     gap: tokens.space.xs,
@@ -76,8 +89,17 @@ export const atoms = {
   gap_xl: {
     gap: tokens.space.xl,
   },
-  gap_xxl: {
-    gap: tokens.space.xxl,
+  gap_2xl: {
+    gap: tokens.space._2xl,
+  },
+  gap_3xl: {
+    gap: tokens.space._3xl,
+  },
+  gap_4xl: {
+    gap: tokens.space._4xl,
+  },
+  gap_5xl: {
+    gap: tokens.space._5xl,
   },
   flex: {
     display: 'flex',
@@ -125,9 +147,9 @@ export const atoms = {
   text_right: {
     textAlign: 'right',
   },
-  text_xxs: {
-    fontSize: tokens.fontSize.xxs,
-    lineHeight: tokens.fontSize.xxs,
+  text_2xs: {
+    fontSize: tokens.fontSize._2xs,
+    lineHeight: tokens.fontSize._2xs,
   },
   text_xs: {
     fontSize: tokens.fontSize.xs,
@@ -149,9 +171,21 @@ export const atoms = {
     fontSize: tokens.fontSize.xl,
     lineHeight: tokens.fontSize.xl,
   },
-  text_xxl: {
-    fontSize: tokens.fontSize.xxl,
-    lineHeight: tokens.fontSize.xxl,
+  text_2xl: {
+    fontSize: tokens.fontSize._2xl,
+    lineHeight: tokens.fontSize._2xl,
+  },
+  text_3xl: {
+    fontSize: tokens.fontSize._3xl,
+    lineHeight: tokens.fontSize._3xl,
+  },
+  text_4xl: {
+    fontSize: tokens.fontSize._4xl,
+    lineHeight: tokens.fontSize._4xl,
+  },
+  text_5xl: {
+    fontSize: tokens.fontSize._5xl,
+    lineHeight: tokens.fontSize._5xl,
   },
   leading_tight: {
     lineHeight: 1.25,
@@ -162,11 +196,8 @@ export const atoms = {
   font_normal: {
     fontWeight: tokens.fontWeight.normal,
   },
-  font_semibold: {
-    fontWeight: tokens.fontWeight.semibold,
-  },
   font_bold: {
-    fontWeight: tokens.fontWeight.bold,
+    fontWeight: tokens.fontWeight.semibold,
   },
 
   /*
@@ -183,10 +214,29 @@ export const atoms = {
   },
 
   /*
+   * Shadow
+   */
+  shadow_sm: {
+    shadowRadius: 8,
+    shadowOpacity: 0.1,
+    elevation: 8,
+  },
+  shadow_md: {
+    shadowRadius: 16,
+    shadowOpacity: 0.1,
+    elevation: 16,
+  },
+  shadow_lg: {
+    shadowRadius: 32,
+    shadowOpacity: 0.1,
+    elevation: 24,
+  },
+
+  /*
    * Padding
    */
-  p_xxs: {
-    padding: tokens.space.xxs,
+  p_2xs: {
+    padding: tokens.space._2xs,
   },
   p_xs: {
     padding: tokens.space.xs,
@@ -203,12 +253,21 @@ export const atoms = {
   p_xl: {
     padding: tokens.space.xl,
   },
-  p_xxl: {
-    padding: tokens.space.xxl,
+  p_2xl: {
+    padding: tokens.space._2xl,
   },
-  px_xxs: {
-    paddingLeft: tokens.space.xxs,
-    paddingRight: tokens.space.xxs,
+  p_3xl: {
+    padding: tokens.space._3xl,
+  },
+  p_4xl: {
+    padding: tokens.space._4xl,
+  },
+  p_5xl: {
+    padding: tokens.space._5xl,
+  },
+  px_2xs: {
+    paddingLeft: tokens.space._2xs,
+    paddingRight: tokens.space._2xs,
   },
   px_xs: {
     paddingLeft: tokens.space.xs,
@@ -230,13 +289,25 @@ export const atoms = {
     paddingLeft: tokens.space.xl,
     paddingRight: tokens.space.xl,
   },
-  px_xxl: {
-    paddingLeft: tokens.space.xxl,
-    paddingRight: tokens.space.xxl,
+  px_2xl: {
+    paddingLeft: tokens.space._2xl,
+    paddingRight: tokens.space._2xl,
+  },
+  px_3xl: {
+    paddingLeft: tokens.space._3xl,
+    paddingRight: tokens.space._3xl,
   },
-  py_xxs: {
-    paddingTop: tokens.space.xxs,
-    paddingBottom: tokens.space.xxs,
+  px_4xl: {
+    paddingLeft: tokens.space._4xl,
+    paddingRight: tokens.space._4xl,
+  },
+  px_5xl: {
+    paddingLeft: tokens.space._5xl,
+    paddingRight: tokens.space._5xl,
+  },
+  py_2xs: {
+    paddingTop: tokens.space._2xs,
+    paddingBottom: tokens.space._2xs,
   },
   py_xs: {
     paddingTop: tokens.space.xs,
@@ -258,12 +329,24 @@ export const atoms = {
     paddingTop: tokens.space.xl,
     paddingBottom: tokens.space.xl,
   },
-  py_xxl: {
-    paddingTop: tokens.space.xxl,
-    paddingBottom: tokens.space.xxl,
+  py_2xl: {
+    paddingTop: tokens.space._2xl,
+    paddingBottom: tokens.space._2xl,
+  },
+  py_3xl: {
+    paddingTop: tokens.space._3xl,
+    paddingBottom: tokens.space._3xl,
+  },
+  py_4xl: {
+    paddingTop: tokens.space._4xl,
+    paddingBottom: tokens.space._4xl,
   },
-  pt_xxs: {
-    paddingTop: tokens.space.xxs,
+  py_5xl: {
+    paddingTop: tokens.space._5xl,
+    paddingBottom: tokens.space._5xl,
+  },
+  pt_2xs: {
+    paddingTop: tokens.space._2xs,
   },
   pt_xs: {
     paddingTop: tokens.space.xs,
@@ -280,11 +363,20 @@ export const atoms = {
   pt_xl: {
     paddingTop: tokens.space.xl,
   },
-  pt_xxl: {
-    paddingTop: tokens.space.xxl,
+  pt_2xl: {
+    paddingTop: tokens.space._2xl,
+  },
+  pt_3xl: {
+    paddingTop: tokens.space._3xl,
+  },
+  pt_4xl: {
+    paddingTop: tokens.space._4xl,
   },
-  pb_xxs: {
-    paddingBottom: tokens.space.xxs,
+  pt_5xl: {
+    paddingTop: tokens.space._5xl,
+  },
+  pb_2xs: {
+    paddingBottom: tokens.space._2xs,
   },
   pb_xs: {
     paddingBottom: tokens.space.xs,
@@ -301,11 +393,20 @@ export const atoms = {
   pb_xl: {
     paddingBottom: tokens.space.xl,
   },
-  pb_xxl: {
-    paddingBottom: tokens.space.xxl,
+  pb_2xl: {
+    paddingBottom: tokens.space._2xl,
+  },
+  pb_3xl: {
+    paddingBottom: tokens.space._3xl,
+  },
+  pb_4xl: {
+    paddingBottom: tokens.space._4xl,
+  },
+  pb_5xl: {
+    paddingBottom: tokens.space._5xl,
   },
-  pl_xxs: {
-    paddingLeft: tokens.space.xxs,
+  pl_2xs: {
+    paddingLeft: tokens.space._2xs,
   },
   pl_xs: {
     paddingLeft: tokens.space.xs,
@@ -322,11 +423,20 @@ export const atoms = {
   pl_xl: {
     paddingLeft: tokens.space.xl,
   },
-  pl_xxl: {
-    paddingLeft: tokens.space.xxl,
+  pl_2xl: {
+    paddingLeft: tokens.space._2xl,
   },
-  pr_xxs: {
-    paddingRight: tokens.space.xxs,
+  pl_3xl: {
+    paddingLeft: tokens.space._3xl,
+  },
+  pl_4xl: {
+    paddingLeft: tokens.space._4xl,
+  },
+  pl_5xl: {
+    paddingLeft: tokens.space._5xl,
+  },
+  pr_2xs: {
+    paddingRight: tokens.space._2xs,
   },
   pr_xs: {
     paddingRight: tokens.space.xs,
@@ -343,15 +453,24 @@ export const atoms = {
   pr_xl: {
     paddingRight: tokens.space.xl,
   },
-  pr_xxl: {
-    paddingRight: tokens.space.xxl,
+  pr_2xl: {
+    paddingRight: tokens.space._2xl,
+  },
+  pr_3xl: {
+    paddingRight: tokens.space._3xl,
+  },
+  pr_4xl: {
+    paddingRight: tokens.space._4xl,
+  },
+  pr_5xl: {
+    paddingRight: tokens.space._5xl,
   },
 
   /*
    * Margin
    */
-  m_xxs: {
-    margin: tokens.space.xxs,
+  m_2xs: {
+    margin: tokens.space._2xs,
   },
   m_xs: {
     margin: tokens.space.xs,
@@ -368,12 +487,21 @@ export const atoms = {
   m_xl: {
     margin: tokens.space.xl,
   },
-  m_xxl: {
-    margin: tokens.space.xxl,
+  m_2xl: {
+    margin: tokens.space._2xl,
+  },
+  m_3xl: {
+    margin: tokens.space._3xl,
+  },
+  m_4xl: {
+    margin: tokens.space._4xl,
   },
-  mx_xxs: {
-    marginLeft: tokens.space.xxs,
-    marginRight: tokens.space.xxs,
+  m_5xl: {
+    margin: tokens.space._5xl,
+  },
+  mx_2xs: {
+    marginLeft: tokens.space._2xs,
+    marginRight: tokens.space._2xs,
   },
   mx_xs: {
     marginLeft: tokens.space.xs,
@@ -395,13 +523,25 @@ export const atoms = {
     marginLeft: tokens.space.xl,
     marginRight: tokens.space.xl,
   },
-  mx_xxl: {
-    marginLeft: tokens.space.xxl,
-    marginRight: tokens.space.xxl,
+  mx_2xl: {
+    marginLeft: tokens.space._2xl,
+    marginRight: tokens.space._2xl,
+  },
+  mx_3xl: {
+    marginLeft: tokens.space._3xl,
+    marginRight: tokens.space._3xl,
+  },
+  mx_4xl: {
+    marginLeft: tokens.space._4xl,
+    marginRight: tokens.space._4xl,
+  },
+  mx_5xl: {
+    marginLeft: tokens.space._5xl,
+    marginRight: tokens.space._5xl,
   },
-  my_xxs: {
-    marginTop: tokens.space.xxs,
-    marginBottom: tokens.space.xxs,
+  my_2xs: {
+    marginTop: tokens.space._2xs,
+    marginBottom: tokens.space._2xs,
   },
   my_xs: {
     marginTop: tokens.space.xs,
@@ -423,12 +563,24 @@ export const atoms = {
     marginTop: tokens.space.xl,
     marginBottom: tokens.space.xl,
   },
-  my_xxl: {
-    marginTop: tokens.space.xxl,
-    marginBottom: tokens.space.xxl,
+  my_2xl: {
+    marginTop: tokens.space._2xl,
+    marginBottom: tokens.space._2xl,
   },
-  mt_xxs: {
-    marginTop: tokens.space.xxs,
+  my_3xl: {
+    marginTop: tokens.space._3xl,
+    marginBottom: tokens.space._3xl,
+  },
+  my_4xl: {
+    marginTop: tokens.space._4xl,
+    marginBottom: tokens.space._4xl,
+  },
+  my_5xl: {
+    marginTop: tokens.space._5xl,
+    marginBottom: tokens.space._5xl,
+  },
+  mt_2xs: {
+    marginTop: tokens.space._2xs,
   },
   mt_xs: {
     marginTop: tokens.space.xs,
@@ -445,11 +597,20 @@ export const atoms = {
   mt_xl: {
     marginTop: tokens.space.xl,
   },
-  mt_xxl: {
-    marginTop: tokens.space.xxl,
+  mt_2xl: {
+    marginTop: tokens.space._2xl,
+  },
+  mt_3xl: {
+    marginTop: tokens.space._3xl,
   },
-  mb_xxs: {
-    marginBottom: tokens.space.xxs,
+  mt_4xl: {
+    marginTop: tokens.space._4xl,
+  },
+  mt_5xl: {
+    marginTop: tokens.space._5xl,
+  },
+  mb_2xs: {
+    marginBottom: tokens.space._2xs,
   },
   mb_xs: {
     marginBottom: tokens.space.xs,
@@ -466,11 +627,20 @@ export const atoms = {
   mb_xl: {
     marginBottom: tokens.space.xl,
   },
-  mb_xxl: {
-    marginBottom: tokens.space.xxl,
+  mb_2xl: {
+    marginBottom: tokens.space._2xl,
+  },
+  mb_3xl: {
+    marginBottom: tokens.space._3xl,
+  },
+  mb_4xl: {
+    marginBottom: tokens.space._4xl,
   },
-  ml_xxs: {
-    marginLeft: tokens.space.xxs,
+  mb_5xl: {
+    marginBottom: tokens.space._5xl,
+  },
+  ml_2xs: {
+    marginLeft: tokens.space._2xs,
   },
   ml_xs: {
     marginLeft: tokens.space.xs,
@@ -487,11 +657,20 @@ export const atoms = {
   ml_xl: {
     marginLeft: tokens.space.xl,
   },
-  ml_xxl: {
-    marginLeft: tokens.space.xxl,
+  ml_2xl: {
+    marginLeft: tokens.space._2xl,
+  },
+  ml_3xl: {
+    marginLeft: tokens.space._3xl,
+  },
+  ml_4xl: {
+    marginLeft: tokens.space._4xl,
+  },
+  ml_5xl: {
+    marginLeft: tokens.space._5xl,
   },
-  mr_xxs: {
-    marginRight: tokens.space.xxs,
+  mr_2xs: {
+    marginRight: tokens.space._2xs,
   },
   mr_xs: {
     marginRight: tokens.space.xs,
@@ -508,7 +687,16 @@ export const atoms = {
   mr_xl: {
     marginRight: tokens.space.xl,
   },
-  mr_xxl: {
-    marginRight: tokens.space.xxl,
+  mr_2xl: {
+    marginRight: tokens.space._2xl,
+  },
+  mr_3xl: {
+    marginRight: tokens.space._3xl,
+  },
+  mr_4xl: {
+    marginRight: tokens.space._4xl,
+  },
+  mr_5xl: {
+    marginRight: tokens.space._5xl,
   },
 } as const
diff --git a/src/alf/index.tsx b/src/alf/index.tsx
index 1daa0bfed..69a879853 100644
--- a/src/alf/index.tsx
+++ b/src/alf/index.tsx
@@ -5,6 +5,7 @@ import * as themes from '#/alf/themes'
 export * as tokens from '#/alf/tokens'
 export {atoms} from '#/alf/atoms'
 export * from '#/alf/util/platform'
+export * from '#/alf/util/flatten'
 
 type BreakpointName = keyof typeof breakpoints
 
diff --git a/src/alf/themes.ts b/src/alf/themes.ts
index aae5c5893..acd180333 100644
--- a/src/alf/themes.ts
+++ b/src/alf/themes.ts
@@ -1,108 +1,320 @@
 import * as tokens from '#/alf/tokens'
 import type {Mutable} from '#/alf/types'
+import {atoms} from '#/alf/atoms'
 
-export type ThemeName = 'light' | 'dark'
+export type ThemeName = 'light' | 'dim' | 'dark'
 export type ReadonlyTheme = typeof light
 export type Theme = Mutable<ReadonlyTheme>
+export type ReadonlyPalette = typeof lightPalette
+export type Palette = Mutable<ReadonlyPalette>
 
-export type Palette = {
-  primary: string
-  positive: string
-  negative: string
-}
+export const lightPalette = {
+  white: tokens.color.gray_0,
+  black: tokens.color.gray_1000,
+
+  contrast_25: tokens.color.gray_25,
+  contrast_50: tokens.color.gray_50,
+  contrast_100: tokens.color.gray_100,
+  contrast_200: tokens.color.gray_200,
+  contrast_300: tokens.color.gray_300,
+  contrast_400: tokens.color.gray_400,
+  contrast_500: tokens.color.gray_500,
+  contrast_600: tokens.color.gray_600,
+  contrast_700: tokens.color.gray_700,
+  contrast_800: tokens.color.gray_800,
+  contrast_900: tokens.color.gray_900,
+  contrast_950: tokens.color.gray_950,
+  contrast_975: tokens.color.gray_975,
+
+  primary_25: tokens.color.blue_25,
+  primary_50: tokens.color.blue_50,
+  primary_100: tokens.color.blue_100,
+  primary_200: tokens.color.blue_200,
+  primary_300: tokens.color.blue_300,
+  primary_400: tokens.color.blue_400,
+  primary_500: tokens.color.blue_500,
+  primary_600: tokens.color.blue_600,
+  primary_700: tokens.color.blue_700,
+  primary_800: tokens.color.blue_800,
+  primary_900: tokens.color.blue_900,
+  primary_950: tokens.color.blue_950,
+  primary_975: tokens.color.blue_975,
+
+  positive_25: tokens.color.green_25,
+  positive_50: tokens.color.green_50,
+  positive_100: tokens.color.green_100,
+  positive_200: tokens.color.green_200,
+  positive_300: tokens.color.green_300,
+  positive_400: tokens.color.green_400,
+  positive_500: tokens.color.green_500,
+  positive_600: tokens.color.green_600,
+  positive_700: tokens.color.green_700,
+  positive_800: tokens.color.green_800,
+  positive_900: tokens.color.green_900,
+  positive_950: tokens.color.green_950,
+  positive_975: tokens.color.green_975,
 
-export const lightPalette: Palette = {
-  primary: tokens.color.blue_500,
-  positive: tokens.color.green_500,
-  negative: tokens.color.red_500,
+  negative_25: tokens.color.red_25,
+  negative_50: tokens.color.red_50,
+  negative_100: tokens.color.red_100,
+  negative_200: tokens.color.red_200,
+  negative_300: tokens.color.red_300,
+  negative_400: tokens.color.red_400,
+  negative_500: tokens.color.red_500,
+  negative_600: tokens.color.red_600,
+  negative_700: tokens.color.red_700,
+  negative_800: tokens.color.red_800,
+  negative_900: tokens.color.red_900,
+  negative_950: tokens.color.red_950,
+  negative_975: tokens.color.red_975,
 } as const
 
 export const darkPalette: Palette = {
-  primary: tokens.color.blue_500,
-  positive: tokens.color.green_400,
-  negative: tokens.color.red_400,
+  white: tokens.color.gray_0,
+  black: tokens.color.gray_1000,
+
+  contrast_25: tokens.color.gray_975,
+  contrast_50: tokens.color.gray_950,
+  contrast_100: tokens.color.gray_900,
+  contrast_200: tokens.color.gray_800,
+  contrast_300: tokens.color.gray_700,
+  contrast_400: tokens.color.gray_600,
+  contrast_500: tokens.color.gray_500,
+  contrast_600: tokens.color.gray_400,
+  contrast_700: tokens.color.gray_300,
+  contrast_800: tokens.color.gray_200,
+  contrast_900: tokens.color.gray_100,
+  contrast_950: tokens.color.gray_50,
+  contrast_975: tokens.color.gray_25,
+
+  primary_25: tokens.color.blue_25,
+  primary_50: tokens.color.blue_50,
+  primary_100: tokens.color.blue_100,
+  primary_200: tokens.color.blue_200,
+  primary_300: tokens.color.blue_300,
+  primary_400: tokens.color.blue_400,
+  primary_500: tokens.color.blue_500,
+  primary_600: tokens.color.blue_600,
+  primary_700: tokens.color.blue_700,
+  primary_800: tokens.color.blue_800,
+  primary_900: tokens.color.blue_900,
+  primary_950: tokens.color.blue_950,
+  primary_975: tokens.color.blue_975,
+
+  positive_25: tokens.color.green_25,
+  positive_50: tokens.color.green_50,
+  positive_100: tokens.color.green_100,
+  positive_200: tokens.color.green_200,
+  positive_300: tokens.color.green_300,
+  positive_400: tokens.color.green_400,
+  positive_500: tokens.color.green_500,
+  positive_600: tokens.color.green_600,
+  positive_700: tokens.color.green_700,
+  positive_800: tokens.color.green_800,
+  positive_900: tokens.color.green_900,
+  positive_950: tokens.color.green_950,
+  positive_975: tokens.color.green_975,
+
+  negative_25: tokens.color.red_25,
+  negative_50: tokens.color.red_50,
+  negative_100: tokens.color.red_100,
+  negative_200: tokens.color.red_200,
+  negative_300: tokens.color.red_300,
+  negative_400: tokens.color.red_400,
+  negative_500: tokens.color.red_500,
+  negative_600: tokens.color.red_600,
+  negative_700: tokens.color.red_700,
+  negative_800: tokens.color.red_800,
+  negative_900: tokens.color.red_900,
+  negative_950: tokens.color.red_950,
+  negative_975: tokens.color.red_975,
 } as const
 
 export const light = {
+  name: 'light',
   palette: lightPalette,
   atoms: {
     text: {
-      color: tokens.color.gray_1000,
+      color: lightPalette.black,
     },
     text_contrast_700: {
-      color: tokens.color.gray_700,
+      color: lightPalette.contrast_700,
+    },
+    text_contrast_600: {
+      color: lightPalette.contrast_600,
     },
     text_contrast_500: {
-      color: tokens.color.gray_500,
+      color: lightPalette.contrast_500,
+    },
+    text_contrast_400: {
+      color: lightPalette.contrast_400,
     },
     text_inverted: {
-      color: tokens.color.white,
+      color: lightPalette.white,
     },
     bg: {
-      backgroundColor: tokens.color.white,
+      backgroundColor: lightPalette.white,
+    },
+    bg_contrast_25: {
+      backgroundColor: lightPalette.contrast_25,
+    },
+    bg_contrast_50: {
+      backgroundColor: lightPalette.contrast_50,
     },
     bg_contrast_100: {
-      backgroundColor: tokens.color.gray_100,
+      backgroundColor: lightPalette.contrast_100,
     },
     bg_contrast_200: {
-      backgroundColor: tokens.color.gray_200,
+      backgroundColor: lightPalette.contrast_200,
     },
     bg_contrast_300: {
-      backgroundColor: tokens.color.gray_300,
+      backgroundColor: lightPalette.contrast_300,
+    },
+    border: {
+      borderColor: lightPalette.contrast_200,
+    },
+    border_contrast: {
+      borderColor: lightPalette.contrast_400,
+    },
+    shadow_sm: {
+      ...atoms.shadow_sm,
+      shadowColor: lightPalette.black,
+    },
+    shadow_md: {
+      ...atoms.shadow_md,
+      shadowColor: lightPalette.black,
     },
-    bg_positive: {
-      backgroundColor: tokens.color.green_500,
+    shadow_lg: {
+      ...atoms.shadow_lg,
+      shadowColor: lightPalette.black,
+    },
+  },
+}
+
+export const dim: Theme = {
+  name: 'dim',
+  palette: darkPalette,
+  atoms: {
+    text: {
+      color: darkPalette.white,
+    },
+    text_contrast_700: {
+      color: darkPalette.contrast_800,
     },
-    bg_negative: {
-      backgroundColor: tokens.color.red_400,
+    text_contrast_600: {
+      color: darkPalette.contrast_700,
+    },
+    text_contrast_500: {
+      color: darkPalette.contrast_600,
+    },
+    text_contrast_400: {
+      color: darkPalette.contrast_500,
+    },
+    text_inverted: {
+      color: darkPalette.black,
+    },
+    bg: {
+      backgroundColor: darkPalette.contrast_50,
+    },
+    bg_contrast_25: {
+      backgroundColor: darkPalette.contrast_100,
+    },
+    bg_contrast_50: {
+      backgroundColor: darkPalette.contrast_200,
+    },
+    bg_contrast_100: {
+      backgroundColor: darkPalette.contrast_300,
+    },
+    bg_contrast_200: {
+      backgroundColor: darkPalette.contrast_400,
+    },
+    bg_contrast_300: {
+      backgroundColor: darkPalette.contrast_500,
     },
     border: {
-      borderColor: tokens.color.gray_200,
+      borderColor: darkPalette.contrast_200,
+    },
+    border_contrast: {
+      borderColor: darkPalette.contrast_400,
     },
-    border_contrast_500: {
-      borderColor: tokens.color.gray_500,
+    shadow_sm: {
+      ...atoms.shadow_sm,
+      shadowOpacity: 0.7,
+      shadowColor: tokens.color.trueBlack,
+    },
+    shadow_md: {
+      ...atoms.shadow_md,
+      shadowOpacity: 0.7,
+      shadowColor: tokens.color.trueBlack,
+    },
+    shadow_lg: {
+      ...atoms.shadow_lg,
+      shadowOpacity: 0.7,
+      shadowColor: tokens.color.trueBlack,
     },
   },
 }
 
 export const dark: Theme = {
+  name: 'dark',
   palette: darkPalette,
   atoms: {
     text: {
-      color: tokens.color.white,
+      color: darkPalette.white,
     },
     text_contrast_700: {
-      color: tokens.color.gray_300,
+      color: darkPalette.contrast_700,
+    },
+    text_contrast_600: {
+      color: darkPalette.contrast_600,
     },
     text_contrast_500: {
-      color: tokens.color.gray_500,
+      color: darkPalette.contrast_500,
+    },
+    text_contrast_400: {
+      color: darkPalette.contrast_400,
     },
     text_inverted: {
-      color: tokens.color.gray_1000,
+      color: darkPalette.black,
     },
     bg: {
-      backgroundColor: tokens.color.gray_1000,
+      backgroundColor: darkPalette.contrast_25,
+    },
+    bg_contrast_25: {
+      backgroundColor: darkPalette.contrast_50,
+    },
+    bg_contrast_50: {
+      backgroundColor: darkPalette.contrast_100,
     },
     bg_contrast_100: {
-      backgroundColor: tokens.color.gray_900,
+      backgroundColor: darkPalette.contrast_200,
     },
     bg_contrast_200: {
-      backgroundColor: tokens.color.gray_800,
+      backgroundColor: darkPalette.contrast_300,
     },
     bg_contrast_300: {
-      backgroundColor: tokens.color.gray_700,
+      backgroundColor: darkPalette.contrast_400,
     },
-    bg_positive: {
-      backgroundColor: tokens.color.green_400,
+    border: {
+      borderColor: darkPalette.contrast_100,
     },
-    bg_negative: {
-      backgroundColor: tokens.color.red_400,
+    border_contrast: {
+      borderColor: darkPalette.contrast_300,
     },
-    border: {
-      borderColor: tokens.color.gray_800,
+    shadow_sm: {
+      ...atoms.shadow_sm,
+      shadowOpacity: 0.7,
+      shadowColor: tokens.color.trueBlack,
+    },
+    shadow_md: {
+      ...atoms.shadow_md,
+      shadowOpacity: 0.7,
+      shadowColor: tokens.color.trueBlack,
     },
-    border_contrast_500: {
-      borderColor: tokens.color.gray_500,
+    shadow_lg: {
+      ...atoms.shadow_lg,
+      shadowOpacity: 0.7,
+      shadowColor: tokens.color.trueBlack,
     },
   },
 }
diff --git a/src/alf/tokens.ts b/src/alf/tokens.ts
index 4034e0deb..0e370cdc1 100644
--- a/src/alf/tokens.ts
+++ b/src/alf/tokens.ts
@@ -1,79 +1,95 @@
 const BLUE_HUE = 211
-const GRAYSCALE_SATURATION = 22
+const RED_HUE = 346
+const GREEN_HUE = 152
 
 export const color = {
-  white: '#FFFFFF',
+  trueBlack: '#000000',
 
-  gray_0: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 100%)`,
-  gray_100: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 95%)`,
-  gray_200: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 85%)`,
-  gray_300: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 75%)`,
-  gray_400: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 65%)`,
-  gray_500: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 55%)`,
-  gray_600: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 45%)`,
-  gray_700: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 35%)`,
-  gray_800: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 25%)`,
-  gray_900: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 15%)`,
-  gray_1000: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 5%)`,
+  gray_0: `hsl(${BLUE_HUE}, 20%, 100%)`,
+  gray_25: `hsl(${BLUE_HUE}, 20%, 97%)`,
+  gray_50: `hsl(${BLUE_HUE}, 20%, 95%)`,
+  gray_100: `hsl(${BLUE_HUE}, 20%, 90%)`,
+  gray_200: `hsl(${BLUE_HUE}, 20%, 80%)`,
+  gray_300: `hsl(${BLUE_HUE}, 20%, 70%)`,
+  gray_400: `hsl(${BLUE_HUE}, 20%, 60%)`,
+  gray_500: `hsl(${BLUE_HUE}, 20%, 50%)`,
+  gray_600: `hsl(${BLUE_HUE}, 20%, 42%)`,
+  gray_700: `hsl(${BLUE_HUE}, 20%, 34%)`,
+  gray_800: `hsl(${BLUE_HUE}, 20%, 26%)`,
+  gray_900: `hsl(${BLUE_HUE}, 20%, 18%)`,
+  gray_950: `hsl(${BLUE_HUE}, 20%, 10%)`,
+  gray_975: `hsl(${BLUE_HUE}, 20%, 7%)`,
+  gray_1000: `hsl(${BLUE_HUE}, 20%, 4%)`,
 
-  blue_0: `hsl(${BLUE_HUE}, 99%, 100%)`,
-  blue_100: `hsl(${BLUE_HUE}, 99%, 93%)`,
-  blue_200: `hsl(${BLUE_HUE}, 99%, 83%)`,
-  blue_300: `hsl(${BLUE_HUE}, 99%, 73%)`,
-  blue_400: `hsl(${BLUE_HUE}, 99%, 63%)`,
+  blue_25: `hsl(${BLUE_HUE}, 99%, 97%)`,
+  blue_50: `hsl(${BLUE_HUE}, 99%, 95%)`,
+  blue_100: `hsl(${BLUE_HUE}, 99%, 90%)`,
+  blue_200: `hsl(${BLUE_HUE}, 99%, 80%)`,
+  blue_300: `hsl(${BLUE_HUE}, 99%, 70%)`,
+  blue_400: `hsl(${BLUE_HUE}, 99%, 60%)`,
   blue_500: `hsl(${BLUE_HUE}, 99%, 53%)`,
-  blue_600: `hsl(${BLUE_HUE}, 99%, 43%)`,
-  blue_700: `hsl(${BLUE_HUE}, 99%, 33%)`,
-  blue_800: `hsl(${BLUE_HUE}, 99%, 23%)`,
-  blue_900: `hsl(${BLUE_HUE}, 99%, 13%)`,
-  blue_1000: `hsl(${BLUE_HUE}, 99%, 8%)`,
+  blue_600: `hsl(${BLUE_HUE}, 99%, 42%)`,
+  blue_700: `hsl(${BLUE_HUE}, 99%, 34%)`,
+  blue_800: `hsl(${BLUE_HUE}, 99%, 26%)`,
+  blue_900: `hsl(${BLUE_HUE}, 99%, 18%)`,
+  blue_950: `hsl(${BLUE_HUE}, 99%, 10%)`,
+  blue_975: `hsl(${BLUE_HUE}, 99%, 7%)`,
 
-  green_0: `hsl(130, 60%, 100%)`,
-  green_100: `hsl(130, 60%, 95%)`,
-  green_200: `hsl(130, 60%, 85%)`,
-  green_300: `hsl(130, 60%, 75%)`,
-  green_400: `hsl(130, 60%, 65%)`,
-  green_500: `hsl(130, 60%, 55%)`,
-  green_600: `hsl(130, 60%, 45%)`,
-  green_700: `hsl(130, 60%, 35%)`,
-  green_800: `hsl(130, 60%, 25%)`,
-  green_900: `hsl(130, 60%, 15%)`,
-  green_1000: `hsl(130, 60%, 5%)`,
+  green_25: `hsl(${GREEN_HUE}, 82%, 97%)`,
+  green_50: `hsl(${GREEN_HUE}, 82%, 95%)`,
+  green_100: `hsl(${GREEN_HUE}, 82%, 90%)`,
+  green_200: `hsl(${GREEN_HUE}, 82%, 80%)`,
+  green_300: `hsl(${GREEN_HUE}, 82%, 70%)`,
+  green_400: `hsl(${GREEN_HUE}, 82%, 60%)`,
+  green_500: `hsl(${GREEN_HUE}, 82%, 50%)`,
+  green_600: `hsl(${GREEN_HUE}, 82%, 42%)`,
+  green_700: `hsl(${GREEN_HUE}, 82%, 34%)`,
+  green_800: `hsl(${GREEN_HUE}, 82%, 26%)`,
+  green_900: `hsl(${GREEN_HUE}, 82%, 18%)`,
+  green_950: `hsl(${GREEN_HUE}, 82%, 10%)`,
+  green_975: `hsl(${GREEN_HUE}, 82%, 7%)`,
 
-  red_0: `hsl(349, 96%, 100%)`,
-  red_100: `hsl(349, 96%, 95%)`,
-  red_200: `hsl(349, 96%, 85%)`,
-  red_300: `hsl(349, 96%, 75%)`,
-  red_400: `hsl(349, 96%, 65%)`,
-  red_500: `hsl(349, 96%, 55%)`,
-  red_600: `hsl(349, 96%, 45%)`,
-  red_700: `hsl(349, 96%, 35%)`,
-  red_800: `hsl(349, 96%, 25%)`,
-  red_900: `hsl(349, 96%, 15%)`,
-  red_1000: `hsl(349, 96%, 5%)`,
+  red_25: `hsl(${RED_HUE}, 91%, 97%)`,
+  red_50: `hsl(${RED_HUE}, 91%, 95%)`,
+  red_100: `hsl(${RED_HUE}, 91%, 90%)`,
+  red_200: `hsl(${RED_HUE}, 91%, 80%)`,
+  red_300: `hsl(${RED_HUE}, 91%, 70%)`,
+  red_400: `hsl(${RED_HUE}, 91%, 60%)`,
+  red_500: `hsl(${RED_HUE}, 91%, 50%)`,
+  red_600: `hsl(${RED_HUE}, 91%, 42%)`,
+  red_700: `hsl(${RED_HUE}, 91%, 34%)`,
+  red_800: `hsl(${RED_HUE}, 91%, 26%)`,
+  red_900: `hsl(${RED_HUE}, 91%, 18%)`,
+  red_950: `hsl(${RED_HUE}, 91%, 10%)`,
+  red_975: `hsl(${RED_HUE}, 91%, 7%)`,
 } as const
 
 export const space = {
-  xxs: 2,
+  _2xs: 2,
   xs: 4,
   sm: 8,
   md: 12,
-  lg: 18,
-  xl: 24,
-  xxl: 32,
+  lg: 16,
+  xl: 20,
+  _2xl: 24,
+  _3xl: 28,
+  _4xl: 32,
+  _5xl: 40,
 } as const
 
 export const fontSize = {
-  xxs: 10,
+  _2xs: 10,
   xs: 12,
   sm: 14,
   md: 16,
   lg: 18,
-  xl: 22,
-  xxl: 26,
+  xl: 20,
+  _2xl: 22,
+  _3xl: 26,
+  _4xl: 32,
+  _5xl: 40,
 } as const
 
-// TODO test
 export const lineHeight = {
   none: 1,
   normal: 1.5,
@@ -81,6 +97,8 @@ export const lineHeight = {
 } as const
 
 export const borderRadius = {
+  _2xs: 2,
+  xs: 4,
   sm: 8,
   md: 12,
   full: 999,
@@ -92,6 +110,56 @@ export const fontWeight = {
   bold: '900',
 } as const
 
+export const gradients = {
+  sky: {
+    values: [
+      [0, '#0A7AFF'],
+      [1, '#59B9FF'],
+    ],
+    hover_value: '#0A7AFF',
+  },
+  midnight: {
+    values: [
+      [0, '#022C5E'],
+      [1, '#4079BC'],
+    ],
+    hover_value: '#022C5E',
+  },
+  sunrise: {
+    values: [
+      [0, '#4E90AE'],
+      [0.4, '#AEA3AB'],
+      [0.8, '#E6A98F'],
+      [1, '#F3A84C'],
+    ],
+    hover_value: '#AEA3AB',
+  },
+  sunset: {
+    values: [
+      [0, '#6772AF'],
+      [0.6, '#B88BB6'],
+      [1, '#FFA6AC'],
+    ],
+    hover_value: '#B88BB6',
+  },
+  nordic: {
+    values: [
+      [0, '#083367'],
+      [1, '#9EE8C1'],
+    ],
+    hover_value: '#3A7085',
+  },
+  bonfire: {
+    values: [
+      [0, '#203E4E'],
+      [0.4, '#755B62'],
+      [0.8, '#CD7765'],
+      [1, '#EF956E'],
+    ],
+    hover_value: '#755B62',
+  },
+} as const
+
 export type Color = keyof typeof color
 export type Space = keyof typeof space
 export type FontSize = keyof typeof fontSize
diff --git a/src/alf/util/flatten.ts b/src/alf/util/flatten.ts
new file mode 100644
index 000000000..448716a08
--- /dev/null
+++ b/src/alf/util/flatten.ts
@@ -0,0 +1,3 @@
+import {StyleSheet} from 'react-native'
+
+export const flatten = StyleSheet.flatten
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
new file mode 100644
index 000000000..d2100f0b4
--- /dev/null
+++ b/src/components/Button.tsx
@@ -0,0 +1,507 @@
+import React from 'react'
+import {
+  Pressable,
+  Text,
+  PressableProps,
+  TextProps,
+  ViewStyle,
+  AccessibilityProps,
+  View,
+  TextStyle,
+  StyleSheet,
+} from 'react-native'
+import LinearGradient from 'react-native-linear-gradient'
+
+import {useTheme, atoms as a, tokens, web, native} from '#/alf'
+import {Props as SVGIconProps} from '#/components/icons/common'
+
+export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient'
+export type ButtonColor =
+  | 'primary'
+  | 'secondary'
+  | 'negative'
+  | 'gradient_sky'
+  | 'gradient_midnight'
+  | 'gradient_sunrise'
+  | 'gradient_sunset'
+  | 'gradient_nordic'
+  | 'gradient_bonfire'
+export type ButtonSize = 'small' | 'large'
+export type VariantProps = {
+  /**
+   * The style variation of the button
+   */
+  variant?: ButtonVariant
+  /**
+   * The color of the button
+   */
+  color?: ButtonColor
+  /**
+   * The size of the button
+   */
+  size?: ButtonSize
+}
+
+export type ButtonProps = React.PropsWithChildren<
+  Pick<PressableProps, 'disabled' | 'onPress'> &
+    AccessibilityProps &
+    VariantProps & {
+      label: string
+    }
+>
+export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean}
+
+const Context = React.createContext<
+  VariantProps & {
+    hovered: boolean
+    focused: boolean
+    pressed: boolean
+    disabled: boolean
+  }
+>({
+  hovered: false,
+  focused: false,
+  pressed: false,
+  disabled: false,
+})
+
+export function useButtonContext() {
+  return React.useContext(Context)
+}
+
+export function Button({
+  children,
+  variant,
+  color,
+  size,
+  label,
+  disabled = false,
+  ...rest
+}: ButtonProps) {
+  const t = useTheme()
+  const [state, setState] = React.useState({
+    pressed: false,
+    hovered: false,
+    focused: false,
+  })
+
+  const onPressIn = React.useCallback(() => {
+    setState(s => ({
+      ...s,
+      pressed: true,
+    }))
+  }, [setState])
+  const onPressOut = React.useCallback(() => {
+    setState(s => ({
+      ...s,
+      pressed: false,
+    }))
+  }, [setState])
+  const onHoverIn = React.useCallback(() => {
+    setState(s => ({
+      ...s,
+      hovered: true,
+    }))
+  }, [setState])
+  const onHoverOut = React.useCallback(() => {
+    setState(s => ({
+      ...s,
+      hovered: false,
+    }))
+  }, [setState])
+  const onFocus = React.useCallback(() => {
+    setState(s => ({
+      ...s,
+      focused: true,
+    }))
+  }, [setState])
+  const onBlur = React.useCallback(() => {
+    setState(s => ({
+      ...s,
+      focused: false,
+    }))
+  }, [setState])
+
+  const {baseStyles, hoverStyles, focusStyles} = React.useMemo(() => {
+    const baseStyles: ViewStyle[] = []
+    const hoverStyles: ViewStyle[] = []
+    const light = t.name === 'light'
+
+    if (color === 'primary') {
+      if (variant === 'solid') {
+        if (!disabled) {
+          baseStyles.push({
+            backgroundColor: t.palette.primary_500,
+          })
+          hoverStyles.push({
+            backgroundColor: t.palette.primary_600,
+          })
+        } else {
+          baseStyles.push({
+            backgroundColor: t.palette.primary_700,
+          })
+        }
+      } else if (variant === 'outline') {
+        baseStyles.push(a.border, t.atoms.bg, {
+          borderWidth: 1,
+        })
+
+        if (!disabled) {
+          baseStyles.push(a.border, {
+            borderColor: tokens.color.blue_500,
+          })
+          hoverStyles.push(a.border, {
+            backgroundColor: light
+              ? t.palette.primary_50
+              : t.palette.primary_950,
+          })
+        } else {
+          baseStyles.push(a.border, {
+            borderColor: light ? tokens.color.blue_200 : tokens.color.blue_900,
+          })
+        }
+      } else if (variant === 'ghost') {
+        if (!disabled) {
+          baseStyles.push(t.atoms.bg)
+          hoverStyles.push({
+            backgroundColor: light
+              ? t.palette.primary_100
+              : t.palette.primary_900,
+          })
+        }
+      }
+    } else if (color === 'secondary') {
+      if (variant === 'solid') {
+        if (!disabled) {
+          baseStyles.push({
+            backgroundColor: light
+              ? tokens.color.gray_100
+              : tokens.color.gray_900,
+          })
+          hoverStyles.push({
+            backgroundColor: light
+              ? tokens.color.gray_200
+              : tokens.color.gray_950,
+          })
+        } else {
+          baseStyles.push({
+            backgroundColor: light
+              ? tokens.color.gray_300
+              : tokens.color.gray_950,
+          })
+        }
+      } else if (variant === 'outline') {
+        baseStyles.push(a.border, t.atoms.bg, {
+          borderWidth: 1,
+        })
+
+        if (!disabled) {
+          baseStyles.push(a.border, {
+            borderColor: light ? tokens.color.gray_500 : tokens.color.gray_500,
+          })
+          hoverStyles.push(a.border, t.atoms.bg_contrast_50)
+        } else {
+          baseStyles.push(a.border, {
+            borderColor: light ? tokens.color.gray_200 : tokens.color.gray_800,
+          })
+        }
+      } else if (variant === 'ghost') {
+        if (!disabled) {
+          baseStyles.push(t.atoms.bg)
+          hoverStyles.push({
+            backgroundColor: light
+              ? tokens.color.gray_100
+              : tokens.color.gray_900,
+          })
+        }
+      }
+    } else if (color === 'negative') {
+      if (variant === 'solid') {
+        if (!disabled) {
+          baseStyles.push({
+            backgroundColor: t.palette.negative_400,
+          })
+          hoverStyles.push({
+            backgroundColor: t.palette.negative_500,
+          })
+        } else {
+          baseStyles.push({
+            backgroundColor: t.palette.negative_600,
+          })
+        }
+      } else if (variant === 'outline') {
+        baseStyles.push(a.border, t.atoms.bg, {
+          borderWidth: 1,
+        })
+
+        if (!disabled) {
+          baseStyles.push(a.border, {
+            borderColor: t.palette.negative_400,
+          })
+          hoverStyles.push(a.border, {
+            backgroundColor: light
+              ? t.palette.negative_50
+              : t.palette.negative_975,
+          })
+        } else {
+          baseStyles.push(a.border, {
+            borderColor: light
+              ? t.palette.negative_200
+              : t.palette.negative_900,
+          })
+        }
+      } else if (variant === 'ghost') {
+        if (!disabled) {
+          baseStyles.push(t.atoms.bg)
+          hoverStyles.push({
+            backgroundColor: light
+              ? t.palette.negative_100
+              : t.palette.negative_950,
+          })
+        }
+      }
+    }
+
+    if (size === 'large') {
+      baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_sm)
+    } else if (size === 'small') {
+      baseStyles.push({paddingVertical: 9}, a.px_md, a.rounded_sm, a.gap_sm)
+    }
+
+    return {
+      baseStyles,
+      hoverStyles,
+      focusStyles: [
+        ...hoverStyles,
+        {
+          outline: 0,
+        } as ViewStyle,
+      ],
+    }
+  }, [t, variant, color, size, disabled])
+
+  const {gradientColors, gradientHoverColors, gradientLocations} =
+    React.useMemo(() => {
+      const colors: string[] = []
+      const hoverColors: string[] = []
+      const locations: number[] = []
+      const gradient = {
+        primary: tokens.gradients.sky,
+        secondary: tokens.gradients.sky,
+        negative: tokens.gradients.sky,
+        gradient_sky: tokens.gradients.sky,
+        gradient_midnight: tokens.gradients.midnight,
+        gradient_sunrise: tokens.gradients.sunrise,
+        gradient_sunset: tokens.gradients.sunset,
+        gradient_nordic: tokens.gradients.nordic,
+        gradient_bonfire: tokens.gradients.bonfire,
+      }[color || 'primary']
+
+      if (variant === 'gradient') {
+        colors.push(...gradient.values.map(([_, color]) => color))
+        hoverColors.push(...gradient.values.map(_ => gradient.hover_value))
+        locations.push(...gradient.values.map(([location, _]) => location))
+      }
+
+      return {
+        gradientColors: colors,
+        gradientHoverColors: hoverColors,
+        gradientLocations: locations,
+      }
+    }, [variant, color])
+
+  const context = React.useMemo(
+    () => ({
+      ...state,
+      variant,
+      color,
+      size,
+      disabled: disabled || false,
+    }),
+    [state, variant, color, size, disabled],
+  )
+
+  return (
+    <Pressable
+      role="button"
+      accessibilityHint={undefined} // optional
+      {...rest}
+      aria-label={label}
+      aria-pressed={state.pressed}
+      accessibilityLabel={label}
+      disabled={disabled || false}
+      accessibilityState={{
+        disabled: disabled || false,
+      }}
+      style={[
+        a.flex_row,
+        a.align_center,
+        a.overflow_hidden,
+        ...baseStyles,
+        ...(state.hovered || state.pressed ? hoverStyles : []),
+        ...(state.focused ? focusStyles : []),
+      ]}
+      onPressIn={onPressIn}
+      onPressOut={onPressOut}
+      onHoverIn={onHoverIn}
+      onHoverOut={onHoverOut}
+      onFocus={onFocus}
+      onBlur={onBlur}>
+      {variant === 'gradient' && (
+        <LinearGradient
+          colors={
+            state.hovered || state.pressed || state.focused
+              ? gradientHoverColors
+              : gradientColors
+          }
+          locations={gradientLocations}
+          start={{x: 0, y: 0}}
+          end={{x: 1, y: 1}}
+          style={[a.absolute, a.inset_0]}
+        />
+      )}
+      <Context.Provider value={context}>
+        {typeof children === 'string' ? (
+          <ButtonText>{children}</ButtonText>
+        ) : (
+          children
+        )}
+      </Context.Provider>
+    </Pressable>
+  )
+}
+
+export function useSharedButtonTextStyles() {
+  const t = useTheme()
+  const {color, variant, disabled, size} = useButtonContext()
+  return React.useMemo(() => {
+    const baseStyles: TextStyle[] = []
+    const light = t.name === 'light'
+
+    if (color === 'primary') {
+      if (variant === 'solid') {
+        if (!disabled) {
+          baseStyles.push({color: t.palette.white})
+        } else {
+          baseStyles.push({color: t.palette.white, opacity: 0.5})
+        }
+      } else if (variant === 'outline') {
+        if (!disabled) {
+          baseStyles.push({
+            color: light ? t.palette.primary_600 : t.palette.primary_500,
+          })
+        } else {
+          baseStyles.push({color: t.palette.primary_600, opacity: 0.5})
+        }
+      } else if (variant === 'ghost') {
+        if (!disabled) {
+          baseStyles.push({color: t.palette.primary_600})
+        } else {
+          baseStyles.push({color: t.palette.primary_600, opacity: 0.5})
+        }
+      }
+    } else if (color === 'secondary') {
+      if (variant === 'solid' || variant === 'gradient') {
+        if (!disabled) {
+          baseStyles.push({
+            color: light ? tokens.color.gray_700 : tokens.color.gray_100,
+          })
+        } else {
+          baseStyles.push({
+            color: light ? tokens.color.gray_400 : tokens.color.gray_700,
+          })
+        }
+      } else if (variant === 'outline') {
+        if (!disabled) {
+          baseStyles.push({
+            color: light ? tokens.color.gray_600 : tokens.color.gray_300,
+          })
+        } else {
+          baseStyles.push({
+            color: light ? tokens.color.gray_400 : tokens.color.gray_700,
+          })
+        }
+      } else if (variant === 'ghost') {
+        if (!disabled) {
+          baseStyles.push({
+            color: light ? tokens.color.gray_600 : tokens.color.gray_300,
+          })
+        } else {
+          baseStyles.push({
+            color: light ? tokens.color.gray_400 : tokens.color.gray_600,
+          })
+        }
+      }
+    } else if (color === 'negative') {
+      if (variant === 'solid' || variant === 'gradient') {
+        if (!disabled) {
+          baseStyles.push({color: t.palette.white})
+        } else {
+          baseStyles.push({color: t.palette.white, opacity: 0.5})
+        }
+      } else if (variant === 'outline') {
+        if (!disabled) {
+          baseStyles.push({color: t.palette.negative_400})
+        } else {
+          baseStyles.push({color: t.palette.negative_400, opacity: 0.5})
+        }
+      } else if (variant === 'ghost') {
+        if (!disabled) {
+          baseStyles.push({color: t.palette.negative_400})
+        } else {
+          baseStyles.push({color: t.palette.negative_400, opacity: 0.5})
+        }
+      }
+    } else {
+      if (!disabled) {
+        baseStyles.push({color: t.palette.white})
+      } else {
+        baseStyles.push({color: t.palette.white, opacity: 0.5})
+      }
+    }
+
+    if (size === 'large') {
+      baseStyles.push(
+        a.text_md,
+        web({paddingBottom: 1}),
+        native({marginTop: 2}),
+      )
+    } else {
+      baseStyles.push(
+        a.text_md,
+        web({paddingBottom: 1}),
+        native({marginTop: 2}),
+      )
+    }
+
+    return StyleSheet.flatten(baseStyles)
+  }, [t, variant, color, size, disabled])
+}
+
+export function ButtonText({children, style, ...rest}: ButtonTextProps) {
+  const textStyles = useSharedButtonTextStyles()
+
+  return (
+    <Text {...rest} style={[a.font_bold, a.text_center, textStyles, style]}>
+      {children}
+    </Text>
+  )
+}
+
+export function ButtonIcon({
+  icon: Comp,
+}: {
+  icon: React.ComponentType<SVGIconProps>
+}) {
+  const {size} = useButtonContext()
+  const textStyles = useSharedButtonTextStyles()
+
+  return (
+    <View style={[a.z_20]}>
+      <Comp
+        size={size === 'large' ? 'md' : 'sm'}
+        style={[{color: textStyles.color, pointerEvents: 'none'}]}
+      />
+    </View>
+  )
+}
diff --git a/src/components/Dialog/context.ts b/src/components/Dialog/context.ts
new file mode 100644
index 000000000..b28b9f5a2
--- /dev/null
+++ b/src/components/Dialog/context.ts
@@ -0,0 +1,35 @@
+import React from 'react'
+
+import {useDialogStateContext} from '#/state/dialogs'
+import {DialogContextProps, DialogControlProps} from '#/components/Dialog/types'
+
+export const Context = React.createContext<DialogContextProps>({
+  close: () => {},
+})
+
+export function useDialogContext() {
+  return React.useContext(Context)
+}
+
+export function useDialogControl() {
+  const id = React.useId()
+  const control = React.useRef<DialogControlProps>({
+    open: () => {},
+    close: () => {},
+  })
+  const {activeDialogs} = useDialogStateContext()
+
+  React.useEffect(() => {
+    activeDialogs.current.set(id, control)
+    return () => {
+      // eslint-disable-next-line react-hooks/exhaustive-deps
+      activeDialogs.current.delete(id)
+    }
+  }, [id, activeDialogs])
+
+  return {
+    ref: control,
+    open: () => control.current.open(),
+    close: () => control.current.close(),
+  }
+}
diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx
new file mode 100644
index 000000000..44e4dc8a7
--- /dev/null
+++ b/src/components/Dialog/index.tsx
@@ -0,0 +1,162 @@
+import React, {useImperativeHandle} from 'react'
+import {View, Dimensions} from 'react-native'
+import BottomSheet, {
+  BottomSheetBackdrop,
+  BottomSheetScrollView,
+  BottomSheetTextInput,
+  BottomSheetView,
+} from '@gorhom/bottom-sheet'
+import {useSafeAreaInsets} from 'react-native-safe-area-context'
+
+import {useTheme, atoms as a} from '#/alf'
+import {Portal} from '#/components/Portal'
+import {createInput} from '#/components/forms/TextField'
+
+import {
+  DialogOuterProps,
+  DialogControlProps,
+  DialogInnerProps,
+} from '#/components/Dialog/types'
+import {Context} from '#/components/Dialog/context'
+
+export {useDialogControl, useDialogContext} from '#/components/Dialog/context'
+export * from '#/components/Dialog/types'
+// @ts-ignore
+export const Input = createInput(BottomSheetTextInput)
+
+export function Outer({
+  children,
+  control,
+  onClose,
+  nativeOptions,
+}: React.PropsWithChildren<DialogOuterProps>) {
+  const t = useTheme()
+  const sheet = React.useRef<BottomSheet>(null)
+  const sheetOptions = nativeOptions?.sheet || {}
+  const hasSnapPoints = !!sheetOptions.snapPoints
+
+  const open = React.useCallback<DialogControlProps['open']>((i = 0) => {
+    sheet.current?.snapToIndex(i)
+  }, [])
+
+  const close = React.useCallback(() => {
+    sheet.current?.close()
+    onClose?.()
+  }, [onClose])
+
+  useImperativeHandle(
+    control.ref,
+    () => ({
+      open,
+      close,
+    }),
+    [open, close],
+  )
+
+  const context = React.useMemo(() => ({close}), [close])
+
+  return (
+    <Portal>
+      <BottomSheet
+        enableDynamicSizing={!hasSnapPoints}
+        enablePanDownToClose
+        keyboardBehavior="interactive"
+        android_keyboardInputMode="adjustResize"
+        keyboardBlurBehavior="restore"
+        {...sheetOptions}
+        ref={sheet}
+        index={-1}
+        backgroundStyle={{backgroundColor: 'transparent'}}
+        backdropComponent={props => (
+          <BottomSheetBackdrop
+            opacity={0.4}
+            appearsOnIndex={0}
+            disappearsOnIndex={-1}
+            {...props}
+          />
+        )}
+        handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
+        handleStyle={{display: 'none'}}
+        onClose={onClose}>
+        <Context.Provider value={context}>
+          <View
+            style={[
+              a.absolute,
+              a.inset_0,
+              t.atoms.bg,
+              {
+                borderTopLeftRadius: 40,
+                borderTopRightRadius: 40,
+                height: Dimensions.get('window').height * 2,
+              },
+            ]}
+          />
+          {children}
+        </Context.Provider>
+      </BottomSheet>
+    </Portal>
+  )
+}
+
+// TODO a11y props here, or is that handled by the sheet?
+export function Inner(props: DialogInnerProps) {
+  const insets = useSafeAreaInsets()
+  return (
+    <BottomSheetView
+      style={[
+        a.p_lg,
+        a.pt_3xl,
+        {
+          borderTopLeftRadius: 40,
+          borderTopRightRadius: 40,
+          paddingBottom: insets.bottom + a.pb_5xl.paddingBottom,
+        },
+      ]}>
+      {props.children}
+    </BottomSheetView>
+  )
+}
+
+export function ScrollableInner(props: DialogInnerProps) {
+  const insets = useSafeAreaInsets()
+  return (
+    <BottomSheetScrollView
+      style={[
+        a.flex_1, // main diff is this
+        a.p_lg,
+        a.pt_3xl,
+        {
+          borderTopLeftRadius: 40,
+          borderTopRightRadius: 40,
+        },
+      ]}>
+      {props.children}
+      <View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
+    </BottomSheetScrollView>
+  )
+}
+
+export function Handle() {
+  const t = useTheme()
+  return (
+    <View
+      style={[
+        a.absolute,
+        a.rounded_sm,
+        a.z_10,
+        {
+          top: a.pt_lg.paddingTop,
+          width: 35,
+          height: 4,
+          alignSelf: 'center',
+          backgroundColor: t.palette.contrast_900,
+          opacity: 0.5,
+        },
+      ]}
+    />
+  )
+}
+
+export function Close() {
+  return null
+}
diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx
new file mode 100644
index 000000000..305c00e97
--- /dev/null
+++ b/src/components/Dialog/index.web.tsx
@@ -0,0 +1,194 @@
+import React, {useImperativeHandle} from 'react'
+import {View, TouchableWithoutFeedback} from 'react-native'
+import {FocusScope} from '@tamagui/focus-scope'
+import Animated, {FadeInDown, FadeIn} from 'react-native-reanimated'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useTheme, atoms as a, useBreakpoints, web} from '#/alf'
+import {Portal} from '#/components/Portal'
+
+import {DialogOuterProps, DialogInnerProps} from '#/components/Dialog/types'
+import {Context} from '#/components/Dialog/context'
+
+export {useDialogControl, useDialogContext} from '#/components/Dialog/context'
+export * from '#/components/Dialog/types'
+export {Input} from '#/components/forms/TextField'
+
+const stopPropagation = (e: any) => e.stopPropagation()
+
+export function Outer({
+  control,
+  onClose,
+  children,
+}: React.PropsWithChildren<DialogOuterProps>) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const {gtMobile} = useBreakpoints()
+  const [isOpen, setIsOpen] = React.useState(false)
+  const [isVisible, setIsVisible] = React.useState(true)
+
+  const open = React.useCallback(() => {
+    setIsOpen(true)
+  }, [setIsOpen])
+
+  const close = React.useCallback(async () => {
+    setIsVisible(false)
+    await new Promise(resolve => setTimeout(resolve, 150))
+    setIsOpen(false)
+    setIsVisible(true)
+    onClose?.()
+  }, [onClose, setIsOpen])
+
+  useImperativeHandle(
+    control.ref,
+    () => ({
+      open,
+      close,
+    }),
+    [open, close],
+  )
+
+  React.useEffect(() => {
+    if (!isOpen) return
+
+    function handler(e: KeyboardEvent) {
+      if (e.key === 'Escape') close()
+    }
+
+    document.addEventListener('keydown', handler)
+
+    return () => document.removeEventListener('keydown', handler)
+  }, [isOpen, close])
+
+  const context = React.useMemo(
+    () => ({
+      close,
+    }),
+    [close],
+  )
+
+  return (
+    <>
+      {isOpen && (
+        <Portal>
+          <Context.Provider value={context}>
+            <TouchableWithoutFeedback
+              accessibilityHint={undefined}
+              accessibilityLabel={_(msg`Close active dialog`)}
+              onPress={close}>
+              <View
+                style={[
+                  web(a.fixed),
+                  a.inset_0,
+                  a.z_10,
+                  a.align_center,
+                  gtMobile ? a.p_lg : a.p_md,
+                  {overflowY: 'auto'},
+                ]}>
+                {isVisible && (
+                  <Animated.View
+                    entering={FadeIn.duration(150)}
+                    // exiting={FadeOut.duration(150)}
+                    style={[
+                      web(a.fixed),
+                      a.inset_0,
+                      {opacity: 0.5, backgroundColor: t.palette.black},
+                    ]}
+                  />
+                )}
+
+                <View
+                  style={[
+                    a.w_full,
+                    a.z_20,
+                    a.justify_center,
+                    a.align_center,
+                    {
+                      minHeight: web('calc(90vh - 36px)') || undefined,
+                    },
+                  ]}>
+                  {isVisible ? children : null}
+                </View>
+              </View>
+            </TouchableWithoutFeedback>
+          </Context.Provider>
+        </Portal>
+      )}
+    </>
+  )
+}
+
+export function Inner({
+  children,
+  style,
+  label,
+  accessibilityLabelledBy,
+  accessibilityDescribedBy,
+}: DialogInnerProps) {
+  const t = useTheme()
+  const {gtMobile} = useBreakpoints()
+  return (
+    <FocusScope loop enabled trapped>
+      <Animated.View
+        role="dialog"
+        aria-role="dialog"
+        aria-label={label}
+        aria-labelledby={accessibilityLabelledBy}
+        aria-describedby={accessibilityDescribedBy}
+        // @ts-ignore web only -prf
+        onClick={stopPropagation}
+        onStartShouldSetResponder={_ => true}
+        onTouchEnd={stopPropagation}
+        entering={FadeInDown.duration(100)}
+        // exiting={FadeOut.duration(100)}
+        style={[
+          a.relative,
+          a.rounded_md,
+          a.w_full,
+          a.border,
+          gtMobile ? a.p_xl : a.p_lg,
+          t.atoms.bg,
+          {
+            maxWidth: 600,
+            borderColor: t.palette.contrast_200,
+            shadowColor: t.palette.black,
+            shadowOpacity: t.name === 'light' ? 0.1 : 0.4,
+            shadowRadius: 30,
+          },
+          ...(Array.isArray(style) ? style : [style || {}]),
+        ]}>
+        {children}
+      </Animated.View>
+    </FocusScope>
+  )
+}
+
+export const ScrollableInner = Inner
+
+export function Handle() {
+  return null
+}
+
+/**
+ * TODO(eric) unused rn
+ */
+// export function Close() {
+//   const {_} = useLingui()
+//   const t = useTheme()
+//   const {close} = useDialogContext()
+//   return (
+//     <View
+//       style={[
+//         a.absolute,
+//         a.z_10,
+//         {
+//           top: a.pt_lg.paddingTop,
+//           right: a.pr_lg.paddingRight,
+//         },
+//       ]}>
+//       <Button onPress={close} label={_(msg`Close active dialog`)}>
+//       </Button>
+//     </View>
+//   )
+// }
diff --git a/src/components/Dialog/types.ts b/src/components/Dialog/types.ts
new file mode 100644
index 000000000..d36784183
--- /dev/null
+++ b/src/components/Dialog/types.ts
@@ -0,0 +1,43 @@
+import React from 'react'
+import type {ViewStyle, AccessibilityProps} from 'react-native'
+import {BottomSheetProps} from '@gorhom/bottom-sheet'
+
+type A11yProps = Required<AccessibilityProps>
+
+export type DialogContextProps = {
+  close: () => void
+}
+
+export type DialogControlProps = {
+  open: (index?: number) => void
+  close: () => void
+}
+
+export type DialogOuterProps = {
+  control: {
+    ref: React.RefObject<DialogControlProps>
+    open: (index?: number) => void
+    close: () => void
+  }
+  onClose?: () => void
+  nativeOptions?: {
+    sheet?: Omit<BottomSheetProps, 'children'>
+  }
+  webOptions?: {}
+}
+
+type DialogInnerPropsBase<T> = React.PropsWithChildren<{
+  style?: ViewStyle
+}> &
+  T
+export type DialogInnerProps =
+  | DialogInnerPropsBase<{
+      label?: undefined
+      accessibilityLabelledBy: A11yProps['aria-labelledby']
+      accessibilityDescribedBy: string
+    }>
+  | DialogInnerPropsBase<{
+      label: string
+      accessibilityLabelledBy?: undefined
+      accessibilityDescribedBy?: undefined
+    }>
diff --git a/src/components/Link.tsx b/src/components/Link.tsx
new file mode 100644
index 000000000..8f686f3c4
--- /dev/null
+++ b/src/components/Link.tsx
@@ -0,0 +1,191 @@
+import React from 'react'
+import {
+  Text,
+  TextStyle,
+  StyleProp,
+  GestureResponderEvent,
+  Linking,
+} from 'react-native'
+import {
+  useLinkProps,
+  useNavigation,
+  StackActions,
+} from '@react-navigation/native'
+import {sanitizeUrl} from '@braintree/sanitize-url'
+
+import {isWeb} from '#/platform/detection'
+import {useTheme, web, flatten} from '#/alf'
+import {Button, ButtonProps, useButtonContext} from '#/components/Button'
+import {AllNavigatorParams, NavigationProp} from '#/lib/routes/types'
+import {
+  convertBskyAppUrlIfNeeded,
+  isExternalUrl,
+  linkRequiresWarning,
+} from '#/lib/strings/url-helpers'
+import {useModalControls} from '#/state/modals'
+import {router} from '#/routes'
+
+export type LinkProps = Omit<
+  ButtonProps,
+  'style' | 'onPress' | 'disabled' | 'label'
+> & {
+  /**
+   * `TextStyle` to apply to the anchor element itself. Does not apply to any children.
+   */
+  style?: StyleProp<TextStyle>
+  /**
+   * The React Navigation `StackAction` to perform when the link is pressed.
+   */
+  action?: 'push' | 'replace' | 'navigate'
+  /**
+   * If true, will warn the user if the link text does not match the href. Only
+   * works for Links with children that are strings i.e. text links.
+   */
+  warnOnMismatchingTextChild?: boolean
+  label?: ButtonProps['label']
+} & Pick<Parameters<typeof useLinkProps<AllNavigatorParams>>[0], 'to'>
+
+/**
+ * A interactive element that renders as a `<a>` tag on the web. On mobile it
+ * will translate the `href` to navigator screens and params and dispatch a
+ * navigation action.
+ *
+ * Intended to behave as a web anchor tag. For more complex routing, use a
+ * `Button`.
+ */
+export function Link({
+  children,
+  to,
+  action = 'push',
+  warnOnMismatchingTextChild,
+  style,
+  ...rest
+}: LinkProps) {
+  const navigation = useNavigation<NavigationProp>()
+  const {href} = useLinkProps<AllNavigatorParams>({
+    to:
+      typeof to === 'string' ? convertBskyAppUrlIfNeeded(sanitizeUrl(to)) : to,
+  })
+  const isExternal = isExternalUrl(href)
+  const {openModal, closeModal} = useModalControls()
+  const onPress = React.useCallback(
+    (e: GestureResponderEvent) => {
+      const stringChildren = typeof children === 'string' ? children : ''
+      const requiresWarning = Boolean(
+        warnOnMismatchingTextChild &&
+          stringChildren &&
+          isExternal &&
+          linkRequiresWarning(href, stringChildren),
+      )
+
+      if (requiresWarning) {
+        e.preventDefault()
+
+        openModal({
+          name: 'link-warning',
+          text: stringChildren,
+          href: href,
+        })
+      } else {
+        e.preventDefault()
+
+        if (isExternal) {
+          Linking.openURL(href)
+        } else {
+          /**
+           * A `GestureResponderEvent`, but cast to `any` to avoid using a bunch
+           * of @ts-ignore below.
+           */
+          const event = e as any
+          const isMiddleClick = isWeb && event.button === 1
+          const isMetaKey =
+            isWeb &&
+            (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey)
+          const shouldOpenInNewTab = isMetaKey || isMiddleClick
+
+          if (
+            shouldOpenInNewTab ||
+            href.startsWith('http') ||
+            href.startsWith('mailto')
+          ) {
+            Linking.openURL(href)
+          } else {
+            closeModal() // close any active modals
+
+            if (action === 'push') {
+              navigation.dispatch(StackActions.push(...router.matchPath(href)))
+            } else if (action === 'replace') {
+              navigation.dispatch(
+                StackActions.replace(...router.matchPath(href)),
+              )
+            } else if (action === 'navigate') {
+              // @ts-ignore
+              navigation.navigate(...router.matchPath(href))
+            } else {
+              throw Error('Unsupported navigator action.')
+            }
+          }
+        }
+      }
+    },
+    [
+      href,
+      isExternal,
+      warnOnMismatchingTextChild,
+      navigation,
+      action,
+      children,
+      closeModal,
+      openModal,
+    ],
+  )
+
+  return (
+    <Button
+      label={href}
+      {...rest}
+      role="link"
+      accessibilityRole="link"
+      href={href}
+      onPress={onPress}
+      {...web({
+        hrefAttrs: {
+          target: isExternal ? 'blank' : undefined,
+          rel: isExternal ? 'noopener noreferrer' : undefined,
+        },
+        dataSet: {
+          // default to no underline, apply this ourselves
+          noUnderline: '1',
+        },
+      })}>
+      {typeof children === 'string' ? (
+        <LinkText style={style}>{children}</LinkText>
+      ) : (
+        children
+      )}
+    </Button>
+  )
+}
+
+function LinkText({
+  children,
+  style,
+}: React.PropsWithChildren<{
+  style?: StyleProp<TextStyle>
+}>) {
+  const t = useTheme()
+  const {hovered} = useButtonContext()
+  return (
+    <Text
+      style={[
+        {color: t.palette.primary_500},
+        hovered && {
+          textDecorationLine: 'underline',
+          textDecorationColor: t.palette.primary_500,
+        },
+        flatten(style),
+      ]}>
+      {children as string}
+    </Text>
+  )
+}
diff --git a/src/components/Portal.tsx b/src/components/Portal.tsx
new file mode 100644
index 000000000..1813d9e05
--- /dev/null
+++ b/src/components/Portal.tsx
@@ -0,0 +1,56 @@
+import React from 'react'
+
+type Component = React.ReactElement
+
+type ContextType = {
+  outlet: Component | null
+  append(id: string, component: Component): void
+  remove(id: string): void
+}
+
+type ComponentMap = {
+  [id: string]: Component
+}
+
+export const Context = React.createContext<ContextType>({
+  outlet: null,
+  append: () => {},
+  remove: () => {},
+})
+
+export function Provider(props: React.PropsWithChildren<{}>) {
+  const map = React.useRef<ComponentMap>({})
+  const [outlet, setOutlet] = React.useState<ContextType['outlet']>(null)
+
+  const append = React.useCallback<ContextType['append']>((id, component) => {
+    if (map.current[id]) return
+    map.current[id] = <React.Fragment key={id}>{component}</React.Fragment>
+    setOutlet(<>{Object.values(map.current)}</>)
+  }, [])
+
+  const remove = React.useCallback<ContextType['remove']>(id => {
+    delete map.current[id]
+    setOutlet(<>{Object.values(map.current)}</>)
+  }, [])
+
+  return (
+    <Context.Provider value={{outlet, append, remove}}>
+      {props.children}
+    </Context.Provider>
+  )
+}
+
+export function Outlet() {
+  const ctx = React.useContext(Context)
+  return ctx.outlet
+}
+
+export function Portal({children}: React.PropsWithChildren<{}>) {
+  const {append, remove} = React.useContext(Context)
+  const id = React.useId()
+  React.useEffect(() => {
+    append(id, children as Component)
+    return () => remove(id)
+  }, [id, children, append, remove])
+  return null
+}
diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx
new file mode 100644
index 000000000..7115f6190
--- /dev/null
+++ b/src/components/Prompt.tsx
@@ -0,0 +1,119 @@
+import React from 'react'
+import {View, PressableProps} from 'react-native'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useTheme, atoms as a} from '#/alf'
+import {H4, P} from '#/components/Typography'
+import {Button} from '#/components/Button'
+
+import * as Dialog from '#/components/Dialog'
+
+export {useDialogControl as usePromptControl} from '#/components/Dialog'
+
+const Context = React.createContext<{
+  titleId: string
+  descriptionId: string
+}>({
+  titleId: '',
+  descriptionId: '',
+})
+
+export function Outer({
+  children,
+  control,
+}: React.PropsWithChildren<{
+  control: Dialog.DialogOuterProps['control']
+}>) {
+  const titleId = React.useId()
+  const descriptionId = React.useId()
+
+  const context = React.useMemo(
+    () => ({titleId, descriptionId}),
+    [titleId, descriptionId],
+  )
+
+  return (
+    <Dialog.Outer control={control}>
+      <Context.Provider value={context}>
+        <Dialog.Handle />
+
+        <Dialog.Inner
+          accessibilityLabelledBy={titleId}
+          accessibilityDescribedBy={descriptionId}
+          style={{width: 'auto', maxWidth: 400}}>
+          {children}
+        </Dialog.Inner>
+      </Context.Provider>
+    </Dialog.Outer>
+  )
+}
+
+export function Title({children}: React.PropsWithChildren<{}>) {
+  const t = useTheme()
+  const {titleId} = React.useContext(Context)
+  return (
+    <H4
+      nativeID={titleId}
+      style={[a.font_bold, t.atoms.text_contrast_700, a.pb_sm]}>
+      {children}
+    </H4>
+  )
+}
+
+export function Description({children}: React.PropsWithChildren<{}>) {
+  const t = useTheme()
+  const {descriptionId} = React.useContext(Context)
+  return (
+    <P nativeID={descriptionId} style={[t.atoms.text, a.pb_lg]}>
+      {children}
+    </P>
+  )
+}
+
+export function Actions({children}: React.PropsWithChildren<{}>) {
+  return (
+    <View style={[a.w_full, a.flex_row, a.gap_sm, a.justify_end]}>
+      {children}
+    </View>
+  )
+}
+
+export function Cancel({
+  children,
+}: React.PropsWithChildren<{onPress?: PressableProps['onPress']}>) {
+  const {_} = useLingui()
+  const {close} = Dialog.useDialogContext()
+  return (
+    <Button
+      variant="solid"
+      color="secondary"
+      size="small"
+      label={_(msg`Cancel`)}
+      onPress={close}>
+      {children}
+    </Button>
+  )
+}
+
+export function Action({
+  children,
+  onPress,
+}: React.PropsWithChildren<{onPress?: () => void}>) {
+  const {_} = useLingui()
+  const {close} = Dialog.useDialogContext()
+  const handleOnPress = React.useCallback(() => {
+    close()
+    onPress?.()
+  }, [close, onPress])
+  return (
+    <Button
+      variant="solid"
+      color="primary"
+      size="small"
+      label={_(msg`Confirm`)}
+      onPress={handleOnPress}>
+      {children}
+    </Button>
+  )
+}
diff --git a/src/view/com/Typography.tsx b/src/components/Typography.tsx
index 6579c2e51..66cf0720d 100644
--- a/src/view/com/Typography.tsx
+++ b/src/components/Typography.tsx
@@ -1,6 +1,7 @@
 import React from 'react'
 import {Text as RNText, TextProps} from 'react-native'
-import {useTheme, atoms, web} from '#/alf'
+
+import {useTheme, atoms, web, flatten} from '#/alf'
 
 export function Text({style, ...rest}: TextProps) {
   const t = useTheme()
@@ -18,7 +19,7 @@ export function H1({style, ...rest}: TextProps) {
     <RNText
       {...attr}
       {...rest}
-      style={[atoms.text_xl, atoms.font_bold, t.atoms.text, style]}
+      style={[atoms.text_5xl, atoms.font_bold, t.atoms.text, flatten(style)]}
     />
   )
 }
@@ -34,7 +35,7 @@ export function H2({style, ...rest}: TextProps) {
     <RNText
       {...attr}
       {...rest}
-      style={[atoms.text_lg, atoms.font_bold, t.atoms.text, style]}
+      style={[atoms.text_4xl, atoms.font_bold, t.atoms.text, flatten(style)]}
     />
   )
 }
@@ -50,7 +51,7 @@ export function H3({style, ...rest}: TextProps) {
     <RNText
       {...attr}
       {...rest}
-      style={[atoms.text_md, atoms.font_bold, t.atoms.text, style]}
+      style={[atoms.text_3xl, atoms.font_bold, t.atoms.text, flatten(style)]}
     />
   )
 }
@@ -66,7 +67,7 @@ export function H4({style, ...rest}: TextProps) {
     <RNText
       {...attr}
       {...rest}
-      style={[atoms.text_sm, atoms.font_bold, t.atoms.text, style]}
+      style={[atoms.text_2xl, atoms.font_bold, t.atoms.text, flatten(style)]}
     />
   )
 }
@@ -82,7 +83,7 @@ export function H5({style, ...rest}: TextProps) {
     <RNText
       {...attr}
       {...rest}
-      style={[atoms.text_xs, atoms.font_bold, t.atoms.text, style]}
+      style={[atoms.text_xl, atoms.font_bold, t.atoms.text, flatten(style)]}
     />
   )
 }
@@ -98,7 +99,26 @@ export function H6({style, ...rest}: TextProps) {
     <RNText
       {...attr}
       {...rest}
-      style={[atoms.text_xxs, atoms.font_bold, t.atoms.text, style]}
+      style={[atoms.text_lg, atoms.font_bold, t.atoms.text, flatten(style)]}
+    />
+  )
+}
+
+export function P({style, ...rest}: TextProps) {
+  const t = useTheme()
+  const attr =
+    web({
+      role: 'paragraph',
+    }) || {}
+  const _style = flatten(style)
+  const lineHeight =
+    (_style?.lineHeight || atoms.text_md.lineHeight) *
+    atoms.leading_normal.lineHeight
+  return (
+    <RNText
+      {...attr}
+      {...rest}
+      style={[atoms.text_md, t.atoms.text, _style, {lineHeight}]}
     />
   )
 }
diff --git a/src/components/forms/DateField/index.android.tsx b/src/components/forms/DateField/index.android.tsx
new file mode 100644
index 000000000..83fa285f5
--- /dev/null
+++ b/src/components/forms/DateField/index.android.tsx
@@ -0,0 +1,108 @@
+import React from 'react'
+import {View, Pressable} from 'react-native'
+import DateTimePicker, {
+  BaseProps as DateTimePickerProps,
+} from '@react-native-community/datetimepicker'
+
+import {useTheme, atoms} from '#/alf'
+import {Text} from '#/components/Typography'
+import {useInteractionState} from '#/components/hooks/useInteractionState'
+import * as TextField from '#/components/forms/TextField'
+import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
+
+import {DateFieldProps} from '#/components/forms/DateField/types'
+import {
+  localizeDate,
+  toSimpleDateString,
+} from '#/components/forms/DateField/utils'
+
+export * as utils from '#/components/forms/DateField/utils'
+export const Label = TextField.Label
+
+export function DateField({
+  value,
+  onChangeDate,
+  label,
+  isInvalid,
+  testID,
+}: DateFieldProps) {
+  const t = useTheme()
+  const [open, setOpen] = React.useState(false)
+  const {
+    state: pressed,
+    onIn: onPressIn,
+    onOut: onPressOut,
+  } = useInteractionState()
+  const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
+
+  const {chromeFocus, chromeError, chromeErrorHover} =
+    TextField.useSharedInputStyles()
+
+  const onChangeInternal = React.useCallback<
+    Required<DateTimePickerProps>['onChange']
+  >(
+    (_event, date) => {
+      setOpen(false)
+
+      if (date) {
+        const formatted = toSimpleDateString(date)
+        onChangeDate(formatted)
+      }
+    },
+    [onChangeDate, setOpen],
+  )
+
+  return (
+    <View style={[atoms.relative, atoms.w_full]}>
+      <Pressable
+        aria-label={label}
+        accessibilityLabel={label}
+        accessibilityHint={undefined}
+        onPress={() => setOpen(true)}
+        onPressIn={onPressIn}
+        onPressOut={onPressOut}
+        onFocus={onFocus}
+        onBlur={onBlur}
+        style={[
+          {
+            paddingTop: 16,
+            paddingBottom: 16,
+            borderColor: 'transparent',
+            borderWidth: 2,
+          },
+          atoms.flex_row,
+          atoms.flex_1,
+          atoms.w_full,
+          atoms.px_lg,
+          atoms.rounded_sm,
+          t.atoms.bg_contrast_50,
+          focused || pressed ? chromeFocus : {},
+          isInvalid ? chromeError : {},
+          isInvalid && (focused || pressed) ? chromeErrorHover : {},
+        ]}>
+        <TextField.Icon icon={CalendarDays} />
+
+        <Text
+          style={[atoms.text_md, atoms.pl_xs, t.atoms.text, {paddingTop: 3}]}>
+          {localizeDate(value)}
+        </Text>
+      </Pressable>
+
+      {open && (
+        <DateTimePicker
+          aria-label={label}
+          accessibilityLabel={label}
+          accessibilityHint={undefined}
+          testID={`${testID}-datepicker`}
+          mode="date"
+          timeZoneName={'Etc/UTC'}
+          display="spinner"
+          // @ts-ignore applies in iOS only -prf
+          themeVariant={t.name === 'dark' ? 'dark' : 'light'}
+          value={new Date(value)}
+          onChange={onChangeInternal}
+        />
+      )}
+    </View>
+  )
+}
diff --git a/src/components/forms/DateField/index.tsx b/src/components/forms/DateField/index.tsx
new file mode 100644
index 000000000..c359a9d46
--- /dev/null
+++ b/src/components/forms/DateField/index.tsx
@@ -0,0 +1,56 @@
+import React from 'react'
+import {View} from 'react-native'
+import DateTimePicker, {
+  DateTimePickerEvent,
+} from '@react-native-community/datetimepicker'
+
+import {useTheme, atoms} from '#/alf'
+import * as TextField from '#/components/forms/TextField'
+import {toSimpleDateString} from '#/components/forms/DateField/utils'
+import {DateFieldProps} from '#/components/forms/DateField/types'
+
+export * as utils from '#/components/forms/DateField/utils'
+export const Label = TextField.Label
+
+/**
+ * Date-only input. Accepts a date in the format YYYY-MM-DD, and reports date
+ * changes in the same format.
+ *
+ * For dates of unknown format, convert with the
+ * `utils.toSimpleDateString(Date)` export of this file.
+ */
+export function DateField({
+  value,
+  onChangeDate,
+  testID,
+  label,
+}: DateFieldProps) {
+  const t = useTheme()
+
+  const onChangeInternal = React.useCallback(
+    (event: DateTimePickerEvent, date: Date | undefined) => {
+      if (date) {
+        const formatted = toSimpleDateString(date)
+        onChangeDate(formatted)
+      }
+    },
+    [onChangeDate],
+  )
+
+  return (
+    <View style={[atoms.relative, atoms.w_full]}>
+      <DateTimePicker
+        aria-label={label}
+        accessibilityLabel={label}
+        accessibilityHint={undefined}
+        testID={`${testID}-datepicker`}
+        mode="date"
+        timeZoneName={'Etc/UTC'}
+        display="spinner"
+        themeVariant={t.name === 'dark' ? 'dark' : 'light'}
+        value={new Date(value)}
+        onChange={onChangeInternal}
+      />
+    </View>
+  )
+}
diff --git a/src/components/forms/DateField/index.web.tsx b/src/components/forms/DateField/index.web.tsx
new file mode 100644
index 000000000..32f38a5d1
--- /dev/null
+++ b/src/components/forms/DateField/index.web.tsx
@@ -0,0 +1,64 @@
+import React from 'react'
+import {TextInput, TextInputProps, StyleSheet} from 'react-native'
+// @ts-ignore
+import {unstable_createElement} from 'react-native-web'
+
+import * as TextField from '#/components/forms/TextField'
+import {toSimpleDateString} from '#/components/forms/DateField/utils'
+import {DateFieldProps} from '#/components/forms/DateField/types'
+
+export * as utils from '#/components/forms/DateField/utils'
+export const Label = TextField.Label
+
+const InputBase = React.forwardRef<HTMLInputElement, TextInputProps>(
+  ({style, ...props}, ref) => {
+    return unstable_createElement('input', {
+      ...props,
+      ref,
+      type: 'date',
+      style: [
+        StyleSheet.flatten(style),
+        {
+          background: 'transparent',
+          border: 0,
+        },
+      ],
+    })
+  },
+)
+
+InputBase.displayName = 'InputBase'
+
+const Input = TextField.createInput(InputBase as unknown as typeof TextInput)
+
+export function DateField({
+  value,
+  onChangeDate,
+  label,
+  isInvalid,
+  testID,
+}: DateFieldProps) {
+  const handleOnChange = React.useCallback(
+    (e: any) => {
+      const date = e.target.valueAsDate || e.target.value
+
+      if (date) {
+        const formatted = toSimpleDateString(date)
+        onChangeDate(formatted)
+      }
+    },
+    [onChangeDate],
+  )
+
+  return (
+    <TextField.Root isInvalid={isInvalid}>
+      <Input
+        value={value}
+        label={label}
+        onChange={handleOnChange}
+        onChangeText={() => {}}
+        testID={testID}
+      />
+    </TextField.Root>
+  )
+}
diff --git a/src/components/forms/DateField/types.ts b/src/components/forms/DateField/types.ts
new file mode 100644
index 000000000..129f5672d
--- /dev/null
+++ b/src/components/forms/DateField/types.ts
@@ -0,0 +1,7 @@
+export type DateFieldProps = {
+  value: string
+  onChangeDate: (date: string) => void
+  label: string
+  isInvalid?: boolean
+  testID?: string
+}
diff --git a/src/components/forms/DateField/utils.ts b/src/components/forms/DateField/utils.ts
new file mode 100644
index 000000000..c787272fe
--- /dev/null
+++ b/src/components/forms/DateField/utils.ts
@@ -0,0 +1,16 @@
+import {getLocales} from 'expo-localization'
+
+const LOCALE = getLocales()[0]
+
+// we need the date in the form yyyy-MM-dd to pass to the input
+export function toSimpleDateString(date: Date | string): string {
+  const _date = typeof date === 'string' ? new Date(date) : date
+  return _date.toISOString().split('T')[0]
+}
+
+export function localizeDate(date: Date | string): string {
+  const _date = typeof date === 'string' ? new Date(date) : date
+  return new Intl.DateTimeFormat(LOCALE.languageTag, {
+    timeZone: 'UTC',
+  }).format(_date)
+}
diff --git a/src/components/forms/InputGroup.tsx b/src/components/forms/InputGroup.tsx
new file mode 100644
index 000000000..6908d4df8
--- /dev/null
+++ b/src/components/forms/InputGroup.tsx
@@ -0,0 +1,43 @@
+import React from 'react'
+import {View} from 'react-native'
+
+import {atoms, useTheme} from '#/alf'
+
+/**
+ * NOT FINISHED, just here as a reference
+ */
+export function InputGroup(props: React.PropsWithChildren<{}>) {
+  const t = useTheme()
+  const children = React.Children.toArray(props.children)
+  const total = children.length
+  return (
+    <View style={[atoms.w_full]}>
+      {children.map((child, i) => {
+        return React.isValidElement(child) ? (
+          <React.Fragment key={i}>
+            {i > 0 ? (
+              <View
+                style={[atoms.border_b, {borderColor: t.palette.contrast_500}]}
+              />
+            ) : null}
+            {React.cloneElement(child, {
+              // @ts-ignore
+              style: [
+                ...(Array.isArray(child.props?.style)
+                  ? child.props.style
+                  : [child.props.style || {}]),
+                {
+                  borderTopLeftRadius: i > 0 ? 0 : undefined,
+                  borderTopRightRadius: i > 0 ? 0 : undefined,
+                  borderBottomLeftRadius: i < total - 1 ? 0 : undefined,
+                  borderBottomRightRadius: i < total - 1 ? 0 : undefined,
+                  borderBottomWidth: i < total - 1 ? 0 : undefined,
+                },
+              ],
+            })}
+          </React.Fragment>
+        ) : null
+      })}
+    </View>
+  )
+}
diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx
new file mode 100644
index 000000000..1ee58303a
--- /dev/null
+++ b/src/components/forms/TextField.tsx
@@ -0,0 +1,334 @@
+import React from 'react'
+import {
+  View,
+  TextInput,
+  TextInputProps,
+  TextStyle,
+  ViewStyle,
+  Pressable,
+  StyleSheet,
+  AccessibilityProps,
+} from 'react-native'
+
+import {HITSLOP_20} from 'lib/constants'
+import {isWeb} from '#/platform/detection'
+import {useTheme, atoms as a, web, tokens, android} from '#/alf'
+import {Text} from '#/components/Typography'
+import {useInteractionState} from '#/components/hooks/useInteractionState'
+import {Props as SVGIconProps} from '#/components/icons/common'
+
+const Context = React.createContext<{
+  inputRef: React.RefObject<TextInput> | null
+  isInvalid: boolean
+  hovered: boolean
+  onHoverIn: () => void
+  onHoverOut: () => void
+  focused: boolean
+  onFocus: () => void
+  onBlur: () => void
+}>({
+  inputRef: null,
+  isInvalid: false,
+  hovered: false,
+  onHoverIn: () => {},
+  onHoverOut: () => {},
+  focused: false,
+  onFocus: () => {},
+  onBlur: () => {},
+})
+
+export type RootProps = React.PropsWithChildren<{isInvalid?: boolean}>
+
+export function Root({children, isInvalid = false}: RootProps) {
+  const inputRef = React.useRef<TextInput>(null)
+  const rootRef = React.useRef<View>(null)
+  const {
+    state: hovered,
+    onIn: onHoverIn,
+    onOut: onHoverOut,
+  } = useInteractionState()
+  const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
+
+  const context = React.useMemo(
+    () => ({
+      inputRef,
+      hovered,
+      onHoverIn,
+      onHoverOut,
+      focused,
+      onFocus,
+      onBlur,
+      isInvalid,
+    }),
+    [
+      inputRef,
+      hovered,
+      onHoverIn,
+      onHoverOut,
+      focused,
+      onFocus,
+      onBlur,
+      isInvalid,
+    ],
+  )
+
+  React.useLayoutEffect(() => {
+    const root = rootRef.current
+    if (!root || !isWeb) return
+    // @ts-ignore web only
+    root.tabIndex = -1
+  }, [])
+
+  return (
+    <Context.Provider value={context}>
+      <Pressable
+        accessibilityRole="button"
+        ref={rootRef}
+        role="none"
+        style={[
+          a.flex_row,
+          a.align_center,
+          a.relative,
+          a.w_full,
+          a.px_md,
+          {
+            paddingVertical: 14,
+          },
+        ]}
+        // onPressIn/out don't work on android web
+        onPress={() => inputRef.current?.focus()}
+        onHoverIn={onHoverIn}
+        onHoverOut={onHoverOut}>
+        {children}
+      </Pressable>
+    </Context.Provider>
+  )
+}
+
+export function useSharedInputStyles() {
+  const t = useTheme()
+  return React.useMemo(() => {
+    const hover: ViewStyle[] = [
+      {
+        borderColor: t.palette.contrast_100,
+      },
+    ]
+    const focus: ViewStyle[] = [
+      {
+        backgroundColor: t.palette.contrast_50,
+        borderColor: t.palette.primary_500,
+      },
+    ]
+    const error: ViewStyle[] = [
+      {
+        backgroundColor:
+          t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
+        borderColor:
+          t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800,
+      },
+    ]
+    const errorHover: ViewStyle[] = [
+      {
+        backgroundColor:
+          t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
+        borderColor: tokens.color.red_500,
+      },
+    ]
+
+    return {
+      chromeHover: StyleSheet.flatten(hover),
+      chromeFocus: StyleSheet.flatten(focus),
+      chromeError: StyleSheet.flatten(error),
+      chromeErrorHover: StyleSheet.flatten(errorHover),
+    }
+  }, [t])
+}
+
+export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & {
+  label: string
+  value: string
+  onChangeText: (value: string) => void
+  isInvalid?: boolean
+}
+
+export function createInput(Component: typeof TextInput) {
+  return function Input({
+    label,
+    placeholder,
+    value,
+    onChangeText,
+    isInvalid,
+    ...rest
+  }: InputProps) {
+    const t = useTheme()
+    const ctx = React.useContext(Context)
+    const withinRoot = Boolean(ctx.inputRef)
+
+    const {chromeHover, chromeFocus, chromeError, chromeErrorHover} =
+      useSharedInputStyles()
+
+    if (!withinRoot) {
+      return (
+        <Root isInvalid={isInvalid}>
+          <Input
+            label={label}
+            placeholder={placeholder}
+            value={value}
+            onChangeText={onChangeText}
+            isInvalid={isInvalid}
+            {...rest}
+          />
+        </Root>
+      )
+    }
+
+    return (
+      <>
+        <Component
+          accessibilityHint={undefined}
+          {...rest}
+          aria-label={label}
+          accessibilityLabel={label}
+          ref={ctx.inputRef}
+          value={value}
+          onChangeText={onChangeText}
+          onFocus={ctx.onFocus}
+          onBlur={ctx.onBlur}
+          placeholder={placeholder || label}
+          placeholderTextColor={t.palette.contrast_500}
+          hitSlop={HITSLOP_20}
+          style={[
+            a.relative,
+            a.z_20,
+            a.flex_1,
+            a.text_md,
+            t.atoms.text,
+            a.px_xs,
+            android({
+              paddingBottom: 2,
+            }),
+            {
+              lineHeight: a.text_md.lineHeight * 1.1875,
+              textAlignVertical: rest.multiline ? 'top' : undefined,
+              minHeight: rest.multiline ? 60 : undefined,
+            },
+          ]}
+        />
+
+        <View
+          style={[
+            a.z_10,
+            a.absolute,
+            a.inset_0,
+            a.rounded_sm,
+            t.atoms.bg_contrast_25,
+            {borderColor: 'transparent', borderWidth: 2},
+            ctx.hovered ? chromeHover : {},
+            ctx.focused ? chromeFocus : {},
+            ctx.isInvalid || isInvalid ? chromeError : {},
+            (ctx.isInvalid || isInvalid) && (ctx.hovered || ctx.focused)
+              ? chromeErrorHover
+              : {},
+          ]}
+        />
+      </>
+    )
+  }
+}
+
+export const Input = createInput(TextInput)
+
+export function Label({children}: React.PropsWithChildren<{}>) {
+  const t = useTheme()
+  return (
+    <Text style={[a.text_sm, a.font_bold, t.atoms.text_contrast_600, a.mb_sm]}>
+      {children}
+    </Text>
+  )
+}
+
+export function Icon({icon: Comp}: {icon: React.ComponentType<SVGIconProps>}) {
+  const t = useTheme()
+  const ctx = React.useContext(Context)
+  const {hover, focus, errorHover, errorFocus} = React.useMemo(() => {
+    const hover: TextStyle[] = [
+      {
+        color: t.palette.contrast_800,
+      },
+    ]
+    const focus: TextStyle[] = [
+      {
+        color: t.palette.primary_500,
+      },
+    ]
+    const errorHover: TextStyle[] = [
+      {
+        color: t.palette.negative_500,
+      },
+    ]
+    const errorFocus: TextStyle[] = [
+      {
+        color: t.palette.negative_500,
+      },
+    ]
+
+    return {
+      hover,
+      focus,
+      errorHover,
+      errorFocus,
+    }
+  }, [t])
+
+  return (
+    <View style={[a.z_20, a.pr_xs]}>
+      <Comp
+        size="md"
+        style={[
+          {color: t.palette.contrast_500, pointerEvents: 'none'},
+          ctx.hovered ? hover : {},
+          ctx.focused ? focus : {},
+          ctx.isInvalid && ctx.hovered ? errorHover : {},
+          ctx.isInvalid && ctx.focused ? errorFocus : {},
+        ]}
+      />
+    </View>
+  )
+}
+
+export function Suffix({
+  children,
+  label,
+  accessibilityHint,
+}: React.PropsWithChildren<{
+  label: string
+  accessibilityHint?: AccessibilityProps['accessibilityHint']
+}>) {
+  const t = useTheme()
+  const ctx = React.useContext(Context)
+  return (
+    <Text
+      aria-label={label}
+      accessibilityLabel={label}
+      accessibilityHint={accessibilityHint}
+      style={[
+        a.z_20,
+        a.pr_sm,
+        a.text_md,
+        t.atoms.text_contrast_400,
+        {
+          pointerEvents: 'none',
+        },
+        web({
+          marginTop: -2,
+        }),
+        ctx.hovered || ctx.focused
+          ? {
+              color: t.palette.contrast_800,
+            }
+          : {},
+      ]}>
+      {children}
+    </Text>
+  )
+}
diff --git a/src/components/forms/Toggle.tsx b/src/components/forms/Toggle.tsx
new file mode 100644
index 000000000..ad82bdff5
--- /dev/null
+++ b/src/components/forms/Toggle.tsx
@@ -0,0 +1,473 @@
+import React from 'react'
+import {Pressable, View, ViewStyle} from 'react-native'
+
+import {HITSLOP_10} from 'lib/constants'
+import {useTheme, atoms as a, web, native} from '#/alf'
+import {Text} from '#/components/Typography'
+import {useInteractionState} from '#/components/hooks/useInteractionState'
+
+export type ItemState = {
+  name: string
+  selected: boolean
+  disabled: boolean
+  isInvalid: boolean
+  hovered: boolean
+  pressed: boolean
+  focused: boolean
+}
+
+const ItemContext = React.createContext<ItemState>({
+  name: '',
+  selected: false,
+  disabled: false,
+  isInvalid: false,
+  hovered: false,
+  pressed: false,
+  focused: false,
+})
+
+const GroupContext = React.createContext<{
+  values: string[]
+  disabled: boolean
+  type: 'radio' | 'checkbox'
+  maxSelectionsReached: boolean
+  setFieldValue: (props: {name: string; value: boolean}) => void
+}>({
+  type: 'checkbox',
+  values: [],
+  disabled: false,
+  maxSelectionsReached: false,
+  setFieldValue: () => {},
+})
+
+export type GroupProps = React.PropsWithChildren<{
+  type?: 'radio' | 'checkbox'
+  values: string[]
+  maxSelections?: number
+  disabled?: boolean
+  onChange: (value: string[]) => void
+  label: string
+}>
+
+export type ItemProps = {
+  type?: 'radio' | 'checkbox'
+  name: string
+  label: string
+  value?: boolean
+  disabled?: boolean
+  onChange?: (selected: boolean) => void
+  isInvalid?: boolean
+  style?: (state: ItemState) => ViewStyle
+  children: ((props: ItemState) => React.ReactNode) | React.ReactNode
+}
+
+export function useItemContext() {
+  return React.useContext(ItemContext)
+}
+
+export function Group({
+  children,
+  values: providedValues,
+  onChange,
+  disabled = false,
+  type = 'checkbox',
+  maxSelections,
+  label,
+}: GroupProps) {
+  const groupRole = type === 'radio' ? 'radiogroup' : undefined
+  const values = type === 'radio' ? providedValues.slice(0, 1) : providedValues
+  const [maxReached, setMaxReached] = React.useState(false)
+
+  const setFieldValue = React.useCallback<
+    (props: {name: string; value: boolean}) => void
+  >(
+    ({name, value}) => {
+      if (type === 'checkbox') {
+        const pruned = values.filter(v => v !== name)
+        const next = value ? pruned.concat(name) : pruned
+        onChange(next)
+      } else {
+        onChange([name])
+      }
+    },
+    [type, onChange, values],
+  )
+
+  React.useEffect(() => {
+    if (type === 'checkbox') {
+      if (
+        maxSelections &&
+        values.length >= maxSelections &&
+        maxReached === false
+      ) {
+        setMaxReached(true)
+      } else if (
+        maxSelections &&
+        values.length < maxSelections &&
+        maxReached === true
+      ) {
+        setMaxReached(false)
+      }
+    }
+  }, [type, values.length, maxSelections, maxReached, setMaxReached])
+
+  const context = React.useMemo(
+    () => ({
+      values,
+      type,
+      disabled,
+      maxSelectionsReached: maxReached,
+      setFieldValue,
+    }),
+    [values, disabled, type, maxReached, setFieldValue],
+  )
+
+  return (
+    <GroupContext.Provider value={context}>
+      <View
+        role={groupRole}
+        {...(groupRole === 'radiogroup'
+          ? {
+              'aria-label': label,
+              accessibilityLabel: label,
+              accessibilityRole: groupRole,
+            }
+          : {})}>
+        {children}
+      </View>
+    </GroupContext.Provider>
+  )
+}
+
+export function Item({
+  children,
+  name,
+  value = false,
+  disabled: itemDisabled = false,
+  onChange,
+  isInvalid,
+  style,
+  type = 'checkbox',
+  label,
+  ...rest
+}: ItemProps) {
+  const {
+    values: selectedValues,
+    type: groupType,
+    disabled: groupDisabled,
+    setFieldValue,
+    maxSelectionsReached,
+  } = React.useContext(GroupContext)
+  const {
+    state: hovered,
+    onIn: onHoverIn,
+    onOut: onHoverOut,
+  } = useInteractionState()
+  const {
+    state: pressed,
+    onIn: onPressIn,
+    onOut: onPressOut,
+  } = useInteractionState()
+  const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
+
+  const role = groupType === 'radio' ? 'radio' : type
+  const selected = selectedValues.includes(name) || !!value
+  const disabled =
+    groupDisabled || itemDisabled || (!selected && maxSelectionsReached)
+
+  const onPress = React.useCallback(() => {
+    const next = !selected
+    setFieldValue({name, value: next})
+    onChange?.(next)
+  }, [name, selected, onChange, setFieldValue])
+
+  const state = React.useMemo(
+    () => ({
+      name,
+      selected,
+      disabled: disabled ?? false,
+      isInvalid: isInvalid ?? false,
+      hovered,
+      pressed,
+      focused,
+    }),
+    [name, selected, disabled, hovered, pressed, focused, isInvalid],
+  )
+
+  return (
+    <ItemContext.Provider value={state}>
+      <Pressable
+        accessibilityHint={undefined} // optional
+        hitSlop={HITSLOP_10}
+        {...rest}
+        disabled={disabled}
+        aria-disabled={disabled ?? false}
+        aria-checked={selected}
+        aria-invalid={isInvalid}
+        aria-label={label}
+        role={role}
+        accessibilityRole={role}
+        accessibilityState={{
+          disabled: disabled ?? false,
+          selected: selected,
+        }}
+        accessibilityLabel={label}
+        onPress={onPress}
+        onHoverIn={onHoverIn}
+        onHoverOut={onHoverOut}
+        onPressIn={onPressIn}
+        onPressOut={onPressOut}
+        onFocus={onFocus}
+        onBlur={onBlur}
+        style={[
+          a.flex_row,
+          a.align_center,
+          a.gap_sm,
+          focused ? web({outline: 'none'}) : {},
+          style?.(state),
+        ]}>
+        {typeof children === 'function' ? children(state) : children}
+      </Pressable>
+    </ItemContext.Provider>
+  )
+}
+
+export function Label({children}: React.PropsWithChildren<{}>) {
+  const t = useTheme()
+  const {disabled} = useItemContext()
+  return (
+    <Text
+      style={[
+        a.font_bold,
+        {
+          userSelect: 'none',
+          color: disabled ? t.palette.contrast_400 : t.palette.contrast_600,
+        },
+        native({
+          paddingTop: 3,
+        }),
+      ]}>
+      {children}
+    </Text>
+  )
+}
+
+// TODO(eric) refactor to memoize styles without knowledge of state
+export function createSharedToggleStyles({
+  theme: t,
+  hovered,
+  focused,
+  selected,
+  disabled,
+  isInvalid,
+}: {
+  theme: ReturnType<typeof useTheme>
+  selected: boolean
+  hovered: boolean
+  focused: boolean
+  disabled: boolean
+  isInvalid: boolean
+}) {
+  const base: ViewStyle[] = []
+  const baseHover: ViewStyle[] = []
+  const indicator: ViewStyle[] = []
+
+  if (selected) {
+    base.push({
+      backgroundColor:
+        t.name === 'light' ? t.palette.primary_25 : t.palette.primary_900,
+      borderColor: t.palette.primary_500,
+    })
+
+    if (hovered || focused) {
+      baseHover.push({
+        backgroundColor:
+          t.name === 'light' ? t.palette.primary_100 : t.palette.primary_800,
+        borderColor:
+          t.name === 'light' ? t.palette.primary_600 : t.palette.primary_400,
+      })
+    }
+  } else {
+    if (hovered || focused) {
+      baseHover.push({
+        backgroundColor:
+          t.name === 'light' ? t.palette.contrast_50 : t.palette.contrast_100,
+        borderColor: t.palette.contrast_500,
+      })
+    }
+  }
+
+  if (isInvalid) {
+    base.push({
+      backgroundColor:
+        t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
+      borderColor:
+        t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800,
+    })
+
+    if (hovered || focused) {
+      baseHover.push({
+        backgroundColor:
+          t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
+        borderColor: t.palette.negative_500,
+      })
+    }
+  }
+
+  if (disabled) {
+    base.push({
+      backgroundColor: t.palette.contrast_100,
+      borderColor: t.palette.contrast_400,
+    })
+  }
+
+  return {
+    baseStyles: base,
+    baseHoverStyles: disabled ? [] : baseHover,
+    indicatorStyles: indicator,
+  }
+}
+
+export function Checkbox() {
+  const t = useTheme()
+  const {selected, hovered, focused, disabled, isInvalid} = useItemContext()
+  const {baseStyles, baseHoverStyles, indicatorStyles} =
+    createSharedToggleStyles({
+      theme: t,
+      hovered,
+      focused,
+      selected,
+      disabled,
+      isInvalid,
+    })
+  return (
+    <View
+      style={[
+        a.justify_center,
+        a.align_center,
+        a.border,
+        a.rounded_xs,
+        t.atoms.border_contrast,
+        {
+          height: 20,
+          width: 20,
+        },
+        baseStyles,
+        hovered || focused ? baseHoverStyles : {},
+      ]}>
+      {selected ? (
+        <View
+          style={[
+            a.absolute,
+            a.rounded_2xs,
+            {height: 12, width: 12},
+            selected
+              ? {
+                  backgroundColor: t.palette.primary_500,
+                }
+              : {},
+            indicatorStyles,
+          ]}
+        />
+      ) : null}
+    </View>
+  )
+}
+
+export function Switch() {
+  const t = useTheme()
+  const {selected, hovered, focused, disabled, isInvalid} = useItemContext()
+  const {baseStyles, baseHoverStyles, indicatorStyles} =
+    createSharedToggleStyles({
+      theme: t,
+      hovered,
+      focused,
+      selected,
+      disabled,
+      isInvalid,
+    })
+  return (
+    <View
+      style={[
+        a.relative,
+        a.border,
+        a.rounded_full,
+        t.atoms.bg,
+        t.atoms.border_contrast,
+        {
+          height: 20,
+          width: 30,
+        },
+        baseStyles,
+        hovered || focused ? baseHoverStyles : {},
+      ]}>
+      <View
+        style={[
+          a.absolute,
+          a.rounded_full,
+          {
+            height: 12,
+            width: 12,
+            top: 3,
+            left: 3,
+            backgroundColor: t.palette.contrast_400,
+          },
+          selected
+            ? {
+                backgroundColor: t.palette.primary_500,
+                left: 13,
+              }
+            : {},
+          indicatorStyles,
+        ]}
+      />
+    </View>
+  )
+}
+
+export function Radio() {
+  const t = useTheme()
+  const {selected, hovered, focused, disabled, isInvalid} =
+    React.useContext(ItemContext)
+  const {baseStyles, baseHoverStyles, indicatorStyles} =
+    createSharedToggleStyles({
+      theme: t,
+      hovered,
+      focused,
+      selected,
+      disabled,
+      isInvalid,
+    })
+  return (
+    <View
+      style={[
+        a.justify_center,
+        a.align_center,
+        a.border,
+        a.rounded_full,
+        t.atoms.border_contrast,
+        {
+          height: 20,
+          width: 20,
+        },
+        baseStyles,
+        hovered || focused ? baseHoverStyles : {},
+      ]}>
+      {selected ? (
+        <View
+          style={[
+            a.absolute,
+            a.rounded_full,
+            {height: 12, width: 12},
+            selected
+              ? {
+                  backgroundColor: t.palette.primary_500,
+                }
+              : {},
+            indicatorStyles,
+          ]}
+        />
+      ) : null}
+    </View>
+  )
+}
diff --git a/src/components/forms/ToggleButton.tsx b/src/components/forms/ToggleButton.tsx
new file mode 100644
index 000000000..615fedae8
--- /dev/null
+++ b/src/components/forms/ToggleButton.tsx
@@ -0,0 +1,124 @@
+import React from 'react'
+import {View, AccessibilityProps, TextStyle, ViewStyle} from 'react-native'
+
+import {atoms as a, useTheme, native} from '#/alf'
+import {Text} from '#/components/Typography'
+
+import * as Toggle from '#/components/forms/Toggle'
+
+export type ItemProps = Omit<Toggle.ItemProps, 'style' | 'role' | 'children'> &
+  AccessibilityProps &
+  React.PropsWithChildren<{}>
+
+export type GroupProps = Omit<Toggle.GroupProps, 'style' | 'type'> & {
+  multiple?: boolean
+}
+
+export function Group({children, multiple, ...props}: GroupProps) {
+  const t = useTheme()
+  return (
+    <Toggle.Group type={multiple ? 'checkbox' : 'radio'} {...props}>
+      <View
+        style={[
+          a.flex_row,
+          a.border,
+          a.rounded_sm,
+          a.overflow_hidden,
+          t.atoms.border,
+        ]}>
+        {children}
+      </View>
+    </Toggle.Group>
+  )
+}
+
+export function Button({children, ...props}: ItemProps) {
+  return (
+    <Toggle.Item {...props}>
+      <ButtonInner>{children}</ButtonInner>
+    </Toggle.Item>
+  )
+}
+
+function ButtonInner({children}: React.PropsWithChildren<{}>) {
+  const t = useTheme()
+  const state = Toggle.useItemContext()
+
+  const {baseStyles, hoverStyles, activeStyles, textStyles} =
+    React.useMemo(() => {
+      const base: ViewStyle[] = []
+      const hover: ViewStyle[] = []
+      const active: ViewStyle[] = []
+      const text: TextStyle[] = []
+
+      hover.push(
+        t.name === 'light' ? t.atoms.bg_contrast_100 : t.atoms.bg_contrast_25,
+      )
+
+      if (state.selected) {
+        active.push({
+          backgroundColor: t.palette.contrast_800,
+        })
+        text.push(t.atoms.text_inverted)
+        hover.push({
+          backgroundColor: t.palette.contrast_800,
+        })
+
+        if (state.disabled) {
+          active.push({
+            backgroundColor: t.palette.contrast_500,
+          })
+        }
+      }
+
+      if (state.disabled) {
+        base.push({
+          backgroundColor: t.palette.contrast_100,
+        })
+        text.push({
+          opacity: 0.5,
+        })
+      }
+
+      return {
+        baseStyles: base,
+        hoverStyles: hover,
+        activeStyles: active,
+        textStyles: text,
+      }
+    }, [t, state])
+
+  return (
+    <View
+      style={[
+        {
+          borderLeftWidth: 1,
+          marginLeft: -1,
+        },
+        a.px_lg,
+        a.py_md,
+        native({
+          paddingTop: 14,
+        }),
+        t.atoms.bg,
+        t.atoms.border,
+        baseStyles,
+        activeStyles,
+        (state.hovered || state.focused || state.pressed) && hoverStyles,
+      ]}>
+      {typeof children === 'string' ? (
+        <Text
+          style={[
+            a.text_center,
+            a.font_bold,
+            t.atoms.text_contrast_500,
+            textStyles,
+          ]}>
+          {children}
+        </Text>
+      ) : (
+        children
+      )}
+    </View>
+  )
+}
diff --git a/src/components/hooks/useInteractionState.ts b/src/components/hooks/useInteractionState.ts
new file mode 100644
index 000000000..653b1c10e
--- /dev/null
+++ b/src/components/hooks/useInteractionState.ts
@@ -0,0 +1,21 @@
+import React from 'react'
+
+export function useInteractionState() {
+  const [state, setState] = React.useState(false)
+
+  const onIn = React.useCallback(() => {
+    setState(true)
+  }, [setState])
+  const onOut = React.useCallback(() => {
+    setState(false)
+  }, [setState])
+
+  return React.useMemo(
+    () => ({
+      state,
+      onIn,
+      onOut,
+    }),
+    [state, onIn, onOut],
+  )
+}
diff --git a/src/components/icons/ArrowTopRight.tsx b/src/components/icons/ArrowTopRight.tsx
new file mode 100644
index 000000000..92ad30a12
--- /dev/null
+++ b/src/components/icons/ArrowTopRight.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const ArrowTopRight_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M8 6a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v9a1 1 0 1 1-2 0V8.414l-9.793 9.793a1 1 0 0 1-1.414-1.414L15.586 7H9a1 1 0 0 1-1-1Z',
+})
diff --git a/src/components/icons/CalendarDays.tsx b/src/components/icons/CalendarDays.tsx
new file mode 100644
index 000000000..72cc48e26
--- /dev/null
+++ b/src/components/icons/CalendarDays.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const CalendarDays_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M4 3a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H4Zm1 16V9h14v10H5ZM5 7h14V5H5v2Zm3 10.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5ZM17.25 12a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0ZM12 13.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5ZM9.25 12a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0ZM12 17.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z',
+})
diff --git a/src/components/icons/ColorPalette.tsx b/src/components/icons/ColorPalette.tsx
new file mode 100644
index 000000000..157fa7fa1
--- /dev/null
+++ b/src/components/icons/ColorPalette.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const ColorPalette_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M4 12c0-4.09 3.527-7.5 8-7.5s8 3.41 8 7.5c0 1.579-.419 2.056-.708 2.236-.388.241-1.031.286-2.058.153-.33-.043-.652-.096-.991-.152a65.905 65.905 0 0 0-.531-.087c-.52-.081-1.077-.156-1.61-.164-1.065-.016-2.336.245-2.996 1.567-.418.834-.295 1.67-.078 2.314.18.534.47 1.055.683 1.437v.001l.097.175.01.018C7.432 19.407 4 16.033 4 12Zm8-9.5C6.532 2.5 2 6.7 2 12s4.532 9.5 10 9.5c.401 0 .812-.04 1.166-.193.41-.176.761-.517.866-1.028.085-.416-.03-.796-.118-1.029a5.981 5.981 0 0 0-.351-.73l-.12-.215c-.215-.392-.403-.73-.52-1.078-.13-.387-.111-.614-.029-.78.146-.291.404-.473 1.178-.461.385.005.825.06 1.329.14.15.023.308.05.47.077.36.059.742.122 1.105.17 1.021.132 2.325.213 3.373-.439C21.496 15.22 22 13.874 22 12c0-5.3-4.532-9.5-10-9.5Zm3.5 8.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM9 12.25a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm1.5-2.75a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z',
+})
diff --git a/src/components/icons/Globe.tsx b/src/components/icons/Globe.tsx
new file mode 100644
index 000000000..f81b3ff7a
--- /dev/null
+++ b/src/components/icons/Globe.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Globe_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M4.062 11h2.961c.103-2.204.545-4.218 1.235-5.77.06-.136.123-.269.188-.399A8.007 8.007 0 0 0 4.062 11ZM12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm0 2c-.227 0-.518.1-.868.432-.354.337-.719.872-1.047 1.61-.561 1.263-.958 2.991-1.06 4.958h5.95c-.102-1.967-.499-3.695-1.06-4.958-.328-.738-.693-1.273-1.047-1.61C12.518 4.099 12.227 4 12 4Zm4.977 7c-.103-2.204-.545-4.218-1.235-5.77a9.78 9.78 0 0 0-.188-.399A8.006 8.006 0 0 1 19.938 11h-2.961Zm-2.003 2H9.026c.101 1.966.498 3.695 1.06 4.958.327.738.692 1.273 1.046 1.61.35.333.641.432.868.432.227 0 .518-.1.868-.432.354-.337.719-.872 1.047-1.61.561-1.263.958-2.991 1.06-4.958Zm.58 6.169c.065-.13.128-.263.188-.399.69-1.552 1.132-3.566 1.235-5.77h2.961a8.006 8.006 0 0 1-4.384 6.169Zm-7.108 0a9.877 9.877 0 0 1-.188-.399c-.69-1.552-1.132-3.566-1.235-5.77H4.062a8.006 8.006 0 0 0 4.384 6.169Z',
+})
diff --git a/src/components/icons/TEMPLATE.tsx b/src/components/icons/TEMPLATE.tsx
new file mode 100644
index 000000000..9fc147037
--- /dev/null
+++ b/src/components/icons/TEMPLATE.tsx
@@ -0,0 +1,48 @@
+import React from 'react'
+import Svg, {Path} from 'react-native-svg'
+
+import {useCommonSVGProps, Props} from '#/components/icons/common'
+
+export const IconTemplate_Stroke2_Corner0_Rounded = React.forwardRef(
+  function LogoImpl(props: Props, ref) {
+    const {fill, size, style, ...rest} = useCommonSVGProps(props)
+
+    return (
+      <Svg
+        fill="none"
+        {...rest}
+        // @ts-ignore it's fiiiiine
+        ref={ref}
+        viewBox="0 0 24 24"
+        width={size}
+        height={size}
+        style={[style]}>
+        <Path
+          fill={fill}
+          fillRule="evenodd"
+          clipRule="evenodd"
+          d="M4.062 11h2.961c.103-2.204.545-4.218 1.235-5.77.06-.136.123-.269.188-.399A8.007 8.007 0 0 0 4.062 11ZM12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm0 2c-.227 0-.518.1-.868.432-.354.337-.719.872-1.047 1.61-.561 1.263-.958 2.991-1.06 4.958h5.95c-.102-1.967-.499-3.695-1.06-4.958-.328-.738-.693-1.273-1.047-1.61C12.518 4.099 12.227 4 12 4Zm4.977 7c-.103-2.204-.545-4.218-1.235-5.77a9.78 9.78 0 0 0-.188-.399A8.006 8.006 0 0 1 19.938 11h-2.961Zm-2.003 2H9.026c.101 1.966.498 3.695 1.06 4.958.327.738.692 1.273 1.046 1.61.35.333.641.432.868.432.227 0 .518-.1.868-.432.354-.337.719-.872 1.047-1.61.561-1.263.958-2.991 1.06-4.958Zm.58 6.169c.065-.13.128-.263.188-.399.69-1.552 1.132-3.566 1.235-5.77h2.961a8.006 8.006 0 0 1-4.384 6.169Zm-7.108 0a9.877 9.877 0 0 1-.188-.399c-.69-1.552-1.132-3.566-1.235-5.77H4.062a8.006 8.006 0 0 0 4.384 6.169Z"
+        />
+      </Svg>
+    )
+  },
+)
+
+export function createSinglePathSVG({path}: {path: string}) {
+  return React.forwardRef<Svg, Props>(function LogoImpl(props, ref) {
+    const {fill, size, style, ...rest} = useCommonSVGProps(props)
+
+    return (
+      <Svg
+        fill="none"
+        {...rest}
+        ref={ref}
+        viewBox="0 0 24 24"
+        width={size}
+        height={size}
+        style={[style]}>
+        <Path fill={fill} fillRule="evenodd" clipRule="evenodd" d={path} />
+      </Svg>
+    )
+  })
+}
diff --git a/src/components/icons/common.ts b/src/components/icons/common.ts
new file mode 100644
index 000000000..9e9f15c4d
--- /dev/null
+++ b/src/components/icons/common.ts
@@ -0,0 +1,32 @@
+import {StyleSheet, TextProps} from 'react-native'
+import type {SvgProps, PathProps} from 'react-native-svg'
+
+import {tokens} from '#/alf'
+
+export type Props = {
+  fill?: PathProps['fill']
+  style?: TextProps['style']
+  size?: keyof typeof sizes
+} & Omit<SvgProps, 'style' | 'size'>
+
+export const sizes = {
+  xs: 12,
+  sm: 16,
+  md: 20,
+  lg: 24,
+  xl: 28,
+}
+
+export function useCommonSVGProps(props: Props) {
+  const {fill, size, ...rest} = props
+  const style = StyleSheet.flatten(rest.style)
+  const _fill = fill || style?.color || tokens.color.blue_500
+  const _size = Number(size ? sizes[size] : rest.width || sizes.md)
+
+  return {
+    fill: _fill,
+    size: _size,
+    style,
+    ...rest,
+  }
+}
diff --git a/src/state/dialogs/index.tsx b/src/state/dialogs/index.tsx
new file mode 100644
index 000000000..4cafaa086
--- /dev/null
+++ b/src/state/dialogs/index.tsx
@@ -0,0 +1,44 @@
+import React from 'react'
+import {DialogControlProps} from '#/components/Dialog'
+
+const DialogContext = React.createContext<{
+  activeDialogs: React.MutableRefObject<
+    Map<string, React.MutableRefObject<DialogControlProps>>
+  >
+}>({
+  activeDialogs: {
+    current: new Map(),
+  },
+})
+
+const DialogControlContext = React.createContext<{
+  closeAllDialogs(): void
+}>({
+  closeAllDialogs: () => {},
+})
+
+export function useDialogStateContext() {
+  return React.useContext(DialogContext)
+}
+
+export function useDialogStateControlContext() {
+  return React.useContext(DialogControlContext)
+}
+
+export function Provider({children}: React.PropsWithChildren<{}>) {
+  const activeDialogs = React.useRef<
+    Map<string, React.MutableRefObject<DialogControlProps>>
+  >(new Map())
+  const closeAllDialogs = React.useCallback(() => {
+    activeDialogs.current.forEach(dialog => dialog.current.close())
+  }, [])
+  const context = React.useMemo(() => ({activeDialogs}), [])
+  const controls = React.useMemo(() => ({closeAllDialogs}), [closeAllDialogs])
+  return (
+    <DialogContext.Provider value={context}>
+      <DialogControlContext.Provider value={controls}>
+        {children}
+      </DialogControlContext.Provider>
+    </DialogContext.Provider>
+  )
+}
diff --git a/src/view/com/Button.tsx b/src/view/com/Button.tsx
deleted file mode 100644
index d1f70d4ae..000000000
--- a/src/view/com/Button.tsx
+++ /dev/null
@@ -1,204 +0,0 @@
-import React from 'react'
-import {Pressable, Text, PressableProps, TextProps} from 'react-native'
-import * as tokens from '#/alf/tokens'
-import {atoms} from '#/alf'
-
-export type ButtonType =
-  | 'primary'
-  | 'secondary'
-  | 'tertiary'
-  | 'positive'
-  | 'negative'
-export type ButtonSize = 'small' | 'large'
-
-export type VariantProps = {
-  type?: ButtonType
-  size?: ButtonSize
-}
-type ButtonState = {
-  pressed: boolean
-  hovered: boolean
-  focused: boolean
-}
-export type ButtonProps = Omit<PressableProps, 'children'> &
-  VariantProps & {
-    children:
-      | ((props: {
-          state: ButtonState
-          type?: ButtonType
-          size?: ButtonSize
-        }) => React.ReactNode)
-      | React.ReactNode
-      | string
-  }
-export type ButtonTextProps = TextProps & VariantProps
-
-export function Button({children, style, type, size, ...rest}: ButtonProps) {
-  const {baseStyles, hoverStyles} = React.useMemo(() => {
-    const baseStyles = []
-    const hoverStyles = []
-
-    switch (type) {
-      case 'primary':
-        baseStyles.push({
-          backgroundColor: tokens.color.blue_500,
-        })
-        break
-      case 'secondary':
-        baseStyles.push({
-          backgroundColor: tokens.color.gray_200,
-        })
-        hoverStyles.push({
-          backgroundColor: tokens.color.gray_100,
-        })
-        break
-      default:
-    }
-
-    switch (size) {
-      case 'large':
-        baseStyles.push(
-          atoms.py_md,
-          atoms.px_xl,
-          atoms.rounded_md,
-          atoms.gap_sm,
-        )
-        break
-      case 'small':
-        baseStyles.push(
-          atoms.py_sm,
-          atoms.px_md,
-          atoms.rounded_sm,
-          atoms.gap_xs,
-        )
-        break
-      default:
-    }
-
-    return {
-      baseStyles,
-      hoverStyles,
-    }
-  }, [type, size])
-
-  const [state, setState] = React.useState({
-    pressed: false,
-    hovered: false,
-    focused: false,
-  })
-
-  const onPressIn = React.useCallback(() => {
-    setState(s => ({
-      ...s,
-      pressed: true,
-    }))
-  }, [setState])
-  const onPressOut = React.useCallback(() => {
-    setState(s => ({
-      ...s,
-      pressed: false,
-    }))
-  }, [setState])
-  const onHoverIn = React.useCallback(() => {
-    setState(s => ({
-      ...s,
-      hovered: true,
-    }))
-  }, [setState])
-  const onHoverOut = React.useCallback(() => {
-    setState(s => ({
-      ...s,
-      hovered: false,
-    }))
-  }, [setState])
-  const onFocus = React.useCallback(() => {
-    setState(s => ({
-      ...s,
-      focused: true,
-    }))
-  }, [setState])
-  const onBlur = React.useCallback(() => {
-    setState(s => ({
-      ...s,
-      focused: false,
-    }))
-  }, [setState])
-
-  return (
-    <Pressable
-      {...rest}
-      style={state => [
-        atoms.flex_row,
-        atoms.align_center,
-        ...baseStyles,
-        ...(state.hovered ? hoverStyles : []),
-        typeof style === 'function' ? style(state) : style,
-      ]}
-      onPressIn={onPressIn}
-      onPressOut={onPressOut}
-      onHoverIn={onHoverIn}
-      onHoverOut={onHoverOut}
-      onFocus={onFocus}
-      onBlur={onBlur}>
-      {typeof children === 'string' ? (
-        <ButtonText type={type} size={size}>
-          {children}
-        </ButtonText>
-      ) : typeof children === 'function' ? (
-        children({state, type, size})
-      ) : (
-        children
-      )}
-    </Pressable>
-  )
-}
-
-export function ButtonText({
-  children,
-  style,
-  type,
-  size,
-  ...rest
-}: ButtonTextProps) {
-  const textStyles = React.useMemo(() => {
-    const base = []
-
-    switch (type) {
-      case 'primary':
-        base.push({color: tokens.color.white})
-        break
-      case 'secondary':
-        base.push({
-          color: tokens.color.gray_700,
-        })
-        break
-      default:
-    }
-
-    switch (size) {
-      case 'small':
-        base.push(atoms.text_sm, {paddingBottom: 1})
-        break
-      case 'large':
-        base.push(atoms.text_md, {paddingBottom: 1})
-        break
-      default:
-    }
-
-    return base
-  }, [type, size])
-
-  return (
-    <Text
-      {...rest}
-      style={[
-        atoms.flex_1,
-        atoms.font_semibold,
-        atoms.text_center,
-        ...textStyles,
-        style,
-      ]}>
-      {children}
-    </Text>
-  )
-}
diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx
index 2c5ba5dfb..9c562f67d 100644
--- a/src/view/com/pager/FeedsTabBarMobile.tsx
+++ b/src/view/com/pager/FeedsTabBarMobile.tsx
@@ -20,6 +20,11 @@ import {useNavigation} from '@react-navigation/native'
 import {NavigationProp} from 'lib/routes/types'
 import {Logo} from '#/view/icons/Logo'
 
+import {IS_DEV} from '#/env'
+import {atoms} from '#/alf'
+import {Link as Link2} from '#/components/Link'
+import {ColorPalette_Stroke2_Corner0_Rounded as ColorPalette} from '#/components/icons/ColorPalette'
+
 export function FeedsTabBar(
   props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
 ) {
@@ -68,7 +73,7 @@ export function FeedsTabBar(
         headerHeight.value = e.nativeEvent.layout.height
       }}>
       <View style={[pal.view, styles.topBar]}>
-        <View style={[pal.view]}>
+        <View style={[pal.view, {width: 100}]}>
           <TouchableOpacity
             testID="viewHeaderDrawerBtn"
             onPress={onPressAvi}
@@ -88,7 +93,21 @@ export function FeedsTabBar(
         <View>
           <Logo width={30} />
         </View>
-        <View style={[pal.view, {width: 18}]}>
+        <View
+          style={[
+            atoms.flex_row,
+            atoms.justify_end,
+            atoms.align_center,
+            atoms.gap_md,
+            pal.view,
+            {width: 100},
+          ]}>
+          {IS_DEV && (
+            <Link2 to="/sys/debug">
+              <ColorPalette size="md" />
+            </Link2>
+          )}
+
           {hasSession && (
             <Link
               testID="viewHeaderHomeFeedPrefsBtn"
diff --git a/src/view/icons/Logo.tsx b/src/view/icons/Logo.tsx
index 15ab5a11c..9212381a9 100644
--- a/src/view/icons/Logo.tsx
+++ b/src/view/icons/Logo.tsx
@@ -1,4 +1,5 @@
 import React from 'react'
+import {StyleSheet, TextProps} from 'react-native'
 import Svg, {
   Path,
   Defs,
@@ -14,12 +15,14 @@ const ratio = 57 / 64
 
 type Props = {
   fill?: PathProps['fill']
-} & SvgProps
+  style?: TextProps['style']
+} & Omit<SvgProps, 'style'>
 
 export const Logo = React.forwardRef(function LogoImpl(props: Props, ref) {
   const {fill, ...rest} = props
   const gradient = fill === 'sky'
-  const _fill = gradient ? 'url(#sky)' : fill || colors.blue3
+  const styles = StyleSheet.flatten(props.style)
+  const _fill = gradient ? 'url(#sky)' : fill || styles?.color || colors.blue3
   // @ts-ignore it's fiiiiine
   const size = parseInt(rest.width || 32)
   return (
@@ -29,7 +32,7 @@ export const Logo = React.forwardRef(function LogoImpl(props: Props, ref) {
       ref={ref}
       viewBox="0 0 64 57"
       {...rest}
-      style={{width: size, height: size * ratio}}>
+      style={[{width: size, height: size * ratio}, styles]}>
       {gradient && (
         <Defs>
           <LinearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
diff --git a/src/view/screens/DebugNew.tsx b/src/view/screens/DebugNew.tsx
deleted file mode 100644
index 0b7c5f03b..000000000
--- a/src/view/screens/DebugNew.tsx
+++ /dev/null
@@ -1,541 +0,0 @@
-import React from 'react'
-import {View} from 'react-native'
-import {CenteredView, ScrollView} from '#/view/com/util/Views'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-
-import {useSetColorMode} from '#/state/shell'
-import * as tokens from '#/alf/tokens'
-import {atoms as a, useTheme, useBreakpoints, ThemeProvider as Alf} from '#/alf'
-import {Button, ButtonText} from '#/view/com/Button'
-import {Text, H1, H2, H3, H4, H5, H6} from '#/view/com/Typography'
-
-function ThemeSelector() {
-  const setColorMode = useSetColorMode()
-
-  return (
-    <View style={[a.flex_row, a.gap_md]}>
-      <Button
-        type="secondary"
-        size="small"
-        onPress={() => setColorMode('system')}>
-        System
-      </Button>
-      <Button
-        type="secondary"
-        size="small"
-        onPress={() => setColorMode('light')}>
-        Light
-      </Button>
-      <Button
-        type="secondary"
-        size="small"
-        onPress={() => setColorMode('dark')}>
-        Dark
-      </Button>
-    </View>
-  )
-}
-
-function BreakpointDebugger() {
-  const t = useTheme()
-  const breakpoints = useBreakpoints()
-
-  return (
-    <View>
-      <H3 style={[a.pb_md]}>Breakpoint Debugger</H3>
-      <Text style={[a.pb_md]}>
-        Current breakpoint: {!breakpoints.gtMobile && <Text>mobile</Text>}
-        {breakpoints.gtMobile && !breakpoints.gtTablet && <Text>tablet</Text>}
-        {breakpoints.gtTablet && <Text>desktop</Text>}
-      </Text>
-      <Text
-        style={[a.p_md, t.atoms.bg_contrast_100, {fontFamily: 'monospace'}]}>
-        {JSON.stringify(breakpoints, null, 2)}
-      </Text>
-    </View>
-  )
-}
-
-function ThemedSection() {
-  const t = useTheme()
-
-  return (
-    <View style={[t.atoms.bg, a.gap_md, a.p_xl]}>
-      <H3 style={[a.font_bold]}>theme.atoms.text</H3>
-      <View style={[a.flex_1, t.atoms.border, a.border_t]} />
-      <H3 style={[a.font_bold, t.atoms.text_contrast_700]}>
-        theme.atoms.text_contrast_700
-      </H3>
-      <View style={[a.flex_1, t.atoms.border, a.border_t]} />
-      <H3 style={[a.font_bold, t.atoms.text_contrast_500]}>
-        theme.atoms.text_contrast_500
-      </H3>
-      <View style={[a.flex_1, t.atoms.border_contrast_500, a.border_t]} />
-
-      <View style={[a.flex_row, a.gap_md]}>
-        <View
-          style={[
-            a.flex_1,
-            t.atoms.bg,
-            a.align_center,
-            a.justify_center,
-            {height: 60},
-          ]}>
-          <Text>theme.bg</Text>
-        </View>
-        <View
-          style={[
-            a.flex_1,
-            t.atoms.bg_contrast_100,
-            a.align_center,
-            a.justify_center,
-            {height: 60},
-          ]}>
-          <Text>theme.bg_contrast_100</Text>
-        </View>
-      </View>
-      <View style={[a.flex_row, a.gap_md]}>
-        <View
-          style={[
-            a.flex_1,
-            t.atoms.bg_contrast_200,
-            a.align_center,
-            a.justify_center,
-            {height: 60},
-          ]}>
-          <Text>theme.bg_contrast_200</Text>
-        </View>
-        <View
-          style={[
-            a.flex_1,
-            t.atoms.bg_contrast_300,
-            a.align_center,
-            a.justify_center,
-            {height: 60},
-          ]}>
-          <Text>theme.bg_contrast_300</Text>
-        </View>
-      </View>
-      <View style={[a.flex_row, a.gap_md]}>
-        <View
-          style={[
-            a.flex_1,
-            t.atoms.bg_positive,
-            a.align_center,
-            a.justify_center,
-            {height: 60},
-          ]}>
-          <Text>theme.bg_positive</Text>
-        </View>
-        <View
-          style={[
-            a.flex_1,
-            t.atoms.bg_negative,
-            a.align_center,
-            a.justify_center,
-            {height: 60},
-          ]}>
-          <Text>theme.bg_negative</Text>
-        </View>
-      </View>
-    </View>
-  )
-}
-
-export function DebugScreen() {
-  const t = useTheme()
-
-  return (
-    <ScrollView>
-      <CenteredView style={[t.atoms.bg]}>
-        <View style={[a.p_xl, a.gap_xxl, {paddingBottom: 200}]}>
-          <ThemeSelector />
-
-          <Alf theme="light">
-            <ThemedSection />
-          </Alf>
-          <Alf theme="dark">
-            <ThemedSection />
-          </Alf>
-
-          <H1>Heading 1</H1>
-          <H2>Heading 2</H2>
-          <H3>Heading 3</H3>
-          <H4>Heading 4</H4>
-          <H5>Heading 5</H5>
-          <H6>Heading 6</H6>
-
-          <Text style={[a.text_xxl]}>atoms.text_xxl</Text>
-          <Text style={[a.text_xl]}>atoms.text_xl</Text>
-          <Text style={[a.text_lg]}>atoms.text_lg</Text>
-          <Text style={[a.text_md]}>atoms.text_md</Text>
-          <Text style={[a.text_sm]}>atoms.text_sm</Text>
-          <Text style={[a.text_xs]}>atoms.text_xs</Text>
-          <Text style={[a.text_xxs]}>atoms.text_xxs</Text>
-
-          <View style={[a.gap_md, a.align_start]}>
-            <Button>
-              {({state}) => (
-                <View style={[a.p_md, a.rounded_full, t.atoms.bg_contrast_300]}>
-                  <Text>Unstyled button, state: {JSON.stringify(state)}</Text>
-                </View>
-              )}
-            </Button>
-
-            <Button type="primary" size="small">
-              Button
-            </Button>
-            <Button type="secondary" size="small">
-              Button
-            </Button>
-
-            <Button type="primary" size="large">
-              Button
-            </Button>
-            <Button type="secondary" size="large">
-              Button
-            </Button>
-
-            <Button type="secondary" size="small">
-              {({type, size}) => (
-                <>
-                  <FontAwesomeIcon icon={['fas', 'plus']} size={12} />
-                  <ButtonText type={type} size={size}>
-                    With an icon
-                  </ButtonText>
-                </>
-              )}
-            </Button>
-            <Button type="primary" size="large">
-              {({state: _state, ...rest}) => (
-                <>
-                  <FontAwesomeIcon icon={['fas', 'plus']} />
-                  <ButtonText {...rest}>With an icon</ButtonText>
-                </>
-              )}
-            </Button>
-          </View>
-
-          <View style={[a.gap_md]}>
-            <View style={[a.flex_row, a.gap_md]}>
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.gray_0},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.gray_100},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.gray_200},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.gray_300},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.gray_400},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.gray_500},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.gray_600},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.gray_700},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.gray_800},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.gray_900},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.gray_1000},
-                ]}
-              />
-            </View>
-
-            <View style={[a.flex_row, a.gap_md]}>
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.blue_0},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.blue_100},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.blue_200},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.blue_300},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.blue_400},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.blue_500},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.blue_600},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.blue_700},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.blue_800},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.blue_900},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.blue_1000},
-                ]}
-              />
-            </View>
-            <View style={[a.flex_row, a.gap_md]}>
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.green_0},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.green_100},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.green_200},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.green_300},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.green_400},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.green_500},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.green_600},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.green_700},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.green_800},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.green_900},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.green_1000},
-                ]}
-              />
-            </View>
-            <View style={[a.flex_row, a.gap_md]}>
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.red_0},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.red_100},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.red_200},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.red_300},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.red_400},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.red_500},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.red_600},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.red_700},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.red_800},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.red_900},
-                ]}
-              />
-              <View
-                style={[
-                  a.flex_1,
-                  {height: 60, backgroundColor: tokens.color.red_1000},
-                ]}
-              />
-            </View>
-          </View>
-
-          <View>
-            <H3 style={[a.pb_md, a.font_bold]}>Spacing</H3>
-
-            <View style={[a.gap_md]}>
-              <View style={[a.flex_row, a.align_center]}>
-                <Text style={{width: 80}}>xxs (2px)</Text>
-                <View style={[a.flex_1, a.pt_xxs, t.atoms.bg_contrast_300]} />
-              </View>
-
-              <View style={[a.flex_row, a.align_center]}>
-                <Text style={{width: 80}}>xs (4px)</Text>
-                <View style={[a.flex_1, a.pt_xs, t.atoms.bg_contrast_300]} />
-              </View>
-
-              <View style={[a.flex_row, a.align_center]}>
-                <Text style={{width: 80}}>sm (8px)</Text>
-                <View style={[a.flex_1, a.pt_sm, t.atoms.bg_contrast_300]} />
-              </View>
-
-              <View style={[a.flex_row, a.align_center]}>
-                <Text style={{width: 80}}>md (12px)</Text>
-                <View style={[a.flex_1, a.pt_md, t.atoms.bg_contrast_300]} />
-              </View>
-
-              <View style={[a.flex_row, a.align_center]}>
-                <Text style={{width: 80}}>lg (18px)</Text>
-                <View style={[a.flex_1, a.pt_lg, t.atoms.bg_contrast_300]} />
-              </View>
-
-              <View style={[a.flex_row, a.align_center]}>
-                <Text style={{width: 80}}>xl (24px)</Text>
-                <View style={[a.flex_1, a.pt_xl, t.atoms.bg_contrast_300]} />
-              </View>
-
-              <View style={[a.flex_row, a.align_center]}>
-                <Text style={{width: 80}}>xxl (32px)</Text>
-                <View style={[a.flex_1, a.pt_xxl, t.atoms.bg_contrast_300]} />
-              </View>
-            </View>
-          </View>
-
-          <BreakpointDebugger />
-        </View>
-      </CenteredView>
-    </ScrollView>
-  )
-}
diff --git a/src/view/screens/Storybook/Breakpoints.tsx b/src/view/screens/Storybook/Breakpoints.tsx
new file mode 100644
index 000000000..1b846d517
--- /dev/null
+++ b/src/view/screens/Storybook/Breakpoints.tsx
@@ -0,0 +1,25 @@
+import React from 'react'
+import {View} from 'react-native'
+
+import {atoms as a, useTheme, useBreakpoints} from '#/alf'
+import {Text, H3} from '#/components/Typography'
+
+export function Breakpoints() {
+  const t = useTheme()
+  const breakpoints = useBreakpoints()
+
+  return (
+    <View>
+      <H3 style={[a.pb_md]}>Breakpoint Debugger</H3>
+      <Text style={[a.pb_md]}>
+        Current breakpoint: {!breakpoints.gtMobile && <Text>mobile</Text>}
+        {breakpoints.gtMobile && !breakpoints.gtTablet && <Text>tablet</Text>}
+        {breakpoints.gtTablet && <Text>desktop</Text>}
+      </Text>
+      <Text
+        style={[a.p_md, t.atoms.bg_contrast_100, {fontFamily: 'monospace'}]}>
+        {JSON.stringify(breakpoints, null, 2)}
+      </Text>
+    </View>
+  )
+}
diff --git a/src/view/screens/Storybook/Buttons.tsx b/src/view/screens/Storybook/Buttons.tsx
new file mode 100644
index 000000000..fbdc84eb4
--- /dev/null
+++ b/src/view/screens/Storybook/Buttons.tsx
@@ -0,0 +1,124 @@
+import React from 'react'
+import {View} from 'react-native'
+
+import {atoms as a} from '#/alf'
+import {
+  Button,
+  ButtonVariant,
+  ButtonColor,
+  ButtonIcon,
+  ButtonText,
+} from '#/components/Button'
+import {H1} from '#/components/Typography'
+import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRight} from '#/components/icons/ArrowTopRight'
+import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
+
+export function Buttons() {
+  return (
+    <View style={[a.gap_md]}>
+      <H1>Buttons</H1>
+
+      <View style={[a.flex_row, a.flex_wrap, a.gap_md, a.align_start]}>
+        {['primary', 'secondary', 'negative'].map(color => (
+          <View key={color} style={[a.gap_md, a.align_start]}>
+            {['solid', 'outline', 'ghost'].map(variant => (
+              <React.Fragment key={variant}>
+                <Button
+                  variant={variant as ButtonVariant}
+                  color={color as ButtonColor}
+                  size="large"
+                  label="Click here">
+                  Button
+                </Button>
+                <Button
+                  disabled
+                  variant={variant as ButtonVariant}
+                  color={color as ButtonColor}
+                  size="large"
+                  label="Click here">
+                  Button
+                </Button>
+              </React.Fragment>
+            ))}
+          </View>
+        ))}
+
+        <View style={[a.flex_row, a.gap_md, a.align_start]}>
+          <View style={[a.gap_md, a.align_start]}>
+            {['gradient_sky', 'gradient_midnight', 'gradient_sunrise'].map(
+              name => (
+                <React.Fragment key={name}>
+                  <Button
+                    variant="gradient"
+                    color={name as ButtonColor}
+                    size="large"
+                    label="Click here">
+                    Button
+                  </Button>
+                  <Button
+                    disabled
+                    variant="gradient"
+                    color={name as ButtonColor}
+                    size="large"
+                    label="Click here">
+                    Button
+                  </Button>
+                </React.Fragment>
+              ),
+            )}
+          </View>
+          <View style={[a.gap_md, a.align_start]}>
+            {['gradient_sunset', 'gradient_nordic', 'gradient_bonfire'].map(
+              name => (
+                <React.Fragment key={name}>
+                  <Button
+                    variant="gradient"
+                    color={name as ButtonColor}
+                    size="large"
+                    label="Click here">
+                    Button
+                  </Button>
+                  <Button
+                    disabled
+                    variant="gradient"
+                    color={name as ButtonColor}
+                    size="large"
+                    label="Click here">
+                    Button
+                  </Button>
+                </React.Fragment>
+              ),
+            )}
+          </View>
+        </View>
+
+        <Button
+          variant="gradient"
+          color="gradient_sky"
+          size="large"
+          label="Link out">
+          <ButtonText>Link out</ButtonText>
+          <ButtonIcon icon={ArrowTopRight} />
+        </Button>
+
+        <Button
+          variant="gradient"
+          color="gradient_sky"
+          size="small"
+          label="Link out">
+          <ButtonText>Link out</ButtonText>
+          <ButtonIcon icon={ArrowTopRight} />
+        </Button>
+
+        <Button
+          variant="gradient"
+          color="gradient_sky"
+          size="small"
+          label="Link out">
+          <ButtonIcon icon={Globe} />
+          <ButtonText>See the world</ButtonText>
+        </Button>
+      </View>
+    </View>
+  )
+}
diff --git a/src/view/screens/Storybook/Dialogs.tsx b/src/view/screens/Storybook/Dialogs.tsx
new file mode 100644
index 000000000..db568c6bd
--- /dev/null
+++ b/src/view/screens/Storybook/Dialogs.tsx
@@ -0,0 +1,90 @@
+import React from 'react'
+import {View} from 'react-native'
+
+import {atoms as a} from '#/alf'
+import {Button} from '#/components/Button'
+import {H3, P} from '#/components/Typography'
+import * as Dialog from '#/components/Dialog'
+import * as Prompt from '#/components/Prompt'
+import {useDialogStateControlContext} from '#/state/dialogs'
+
+export function Dialogs() {
+  const control = Dialog.useDialogControl()
+  const prompt = Prompt.usePromptControl()
+  const {closeAllDialogs} = useDialogStateControlContext()
+
+  return (
+    <View style={[a.gap_md]}>
+      <Button
+        variant="outline"
+        color="secondary"
+        size="small"
+        onPress={() => {
+          control.open()
+          prompt.open()
+        }}
+        label="Open basic dialog">
+        Open basic dialog
+      </Button>
+
+      <Button
+        variant="solid"
+        color="primary"
+        size="small"
+        onPress={() => prompt.open()}
+        label="Open prompt">
+        Open prompt
+      </Button>
+
+      <Prompt.Outer control={prompt}>
+        <Prompt.Title>This is a prompt</Prompt.Title>
+        <Prompt.Description>
+          This is a generic prompt component. It accepts a title and a
+          description, as well as two actions.
+        </Prompt.Description>
+        <Prompt.Actions>
+          <Prompt.Cancel>Cancel</Prompt.Cancel>
+          <Prompt.Action>Confirm</Prompt.Action>
+        </Prompt.Actions>
+      </Prompt.Outer>
+
+      <Dialog.Outer
+        control={control}
+        nativeOptions={{sheet: {snapPoints: ['90%']}}}>
+        <Dialog.Handle />
+
+        <Dialog.ScrollableInner
+          accessibilityDescribedBy="dialog-description"
+          accessibilityLabelledBy="dialog-title">
+          <View style={[a.relative, a.gap_md, a.w_full]}>
+            <H3 nativeID="dialog-title">Dialog</H3>
+            <P nativeID="dialog-description">
+              A scrollable dialog with an input within it.
+            </P>
+            <Dialog.Input value="" onChangeText={() => {}} label="Type here" />
+
+            <Button
+              variant="outline"
+              color="secondary"
+              size="small"
+              onPress={closeAllDialogs}
+              label="Close all dialogs">
+              Close all dialogs
+            </Button>
+            <View style={{height: 1000}} />
+            <View style={[a.flex_row, a.justify_end]}>
+              <Button
+                variant="outline"
+                color="primary"
+                size="small"
+                onPress={() => control.close()}
+                label="Open basic dialog">
+                Close basic dialog
+              </Button>
+            </View>
+          </View>
+        </Dialog.ScrollableInner>
+      </Dialog.Outer>
+    </View>
+  )
+}
diff --git a/src/view/screens/Storybook/Forms.tsx b/src/view/screens/Storybook/Forms.tsx
new file mode 100644
index 000000000..9396cca67
--- /dev/null
+++ b/src/view/screens/Storybook/Forms.tsx
@@ -0,0 +1,215 @@
+import React from 'react'
+import {View} from 'react-native'
+
+import {atoms as a} from '#/alf'
+import {H1, H3} from '#/components/Typography'
+import * as TextField from '#/components/forms/TextField'
+import {DateField, Label} from '#/components/forms/DateField'
+import * as Toggle from '#/components/forms/Toggle'
+import * as ToggleButton from '#/components/forms/ToggleButton'
+import {Button} from '#/components/Button'
+import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
+
+export function Forms() {
+  const [toggleGroupAValues, setToggleGroupAValues] = React.useState(['a'])
+  const [toggleGroupBValues, setToggleGroupBValues] = React.useState(['a', 'b'])
+  const [toggleGroupCValues, setToggleGroupCValues] = React.useState(['a', 'b'])
+  const [toggleGroupDValues, setToggleGroupDValues] = React.useState(['warn'])
+
+  const [value, setValue] = React.useState('')
+  const [date, setDate] = React.useState('2001-01-01')
+
+  return (
+    <View style={[a.gap_4xl, a.align_start]}>
+      <H1>Forms</H1>
+
+      <View style={[a.gap_md, a.align_start, a.w_full]}>
+        <H3>InputText</H3>
+
+        <TextField.Input
+          value={value}
+          onChangeText={setValue}
+          label="Text field"
+        />
+
+        <TextField.Root>
+          <TextField.Icon icon={Globe} />
+          <TextField.Input
+            value={value}
+            onChangeText={setValue}
+            label="Text field"
+          />
+        </TextField.Root>
+
+        <View style={[a.w_full]}>
+          <TextField.Label>Text field</TextField.Label>
+          <TextField.Root>
+            <TextField.Icon icon={Globe} />
+            <TextField.Input
+              value={value}
+              onChangeText={setValue}
+              label="Text field"
+            />
+            <TextField.Suffix label="@gmail.com">@gmail.com</TextField.Suffix>
+          </TextField.Root>
+        </View>
+
+        <View style={[a.w_full]}>
+          <TextField.Label>Textarea</TextField.Label>
+          <TextField.Input
+            multiline
+            numberOfLines={4}
+            value={value}
+            onChangeText={setValue}
+            label="Text field"
+          />
+        </View>
+
+        <H3>DateField</H3>
+
+        <View style={[a.w_full]}>
+          <Label>Date</Label>
+          <DateField
+            testID="date"
+            value={date}
+            onChangeDate={date => {
+              console.log(date)
+              setDate(date)
+            }}
+            label="Input"
+          />
+        </View>
+      </View>
+
+      <View style={[a.gap_md, a.align_start, a.w_full]}>
+        <H3>Toggles</H3>
+
+        <Toggle.Item name="a" label="Click me">
+          <Toggle.Checkbox />
+          <Toggle.Label>Uncontrolled toggle</Toggle.Label>
+        </Toggle.Item>
+
+        <Toggle.Group
+          label="Toggle"
+          type="checkbox"
+          maxSelections={2}
+          values={toggleGroupAValues}
+          onChange={setToggleGroupAValues}>
+          <View style={[a.gap_md]}>
+            <Toggle.Item name="a" label="Click me">
+              <Toggle.Switch />
+              <Toggle.Label>Click me</Toggle.Label>
+            </Toggle.Item>
+            <Toggle.Item name="b" label="Click me">
+              <Toggle.Switch />
+              <Toggle.Label>Click me</Toggle.Label>
+            </Toggle.Item>
+            <Toggle.Item name="c" label="Click me">
+              <Toggle.Switch />
+              <Toggle.Label>Click me</Toggle.Label>
+            </Toggle.Item>
+            <Toggle.Item name="d" disabled label="Click me">
+              <Toggle.Switch />
+              <Toggle.Label>Click me</Toggle.Label>
+            </Toggle.Item>
+            <Toggle.Item name="e" isInvalid label="Click me">
+              <Toggle.Switch />
+              <Toggle.Label>Click me</Toggle.Label>
+            </Toggle.Item>
+          </View>
+        </Toggle.Group>
+
+        <Toggle.Group
+          label="Toggle"
+          type="checkbox"
+          maxSelections={2}
+          values={toggleGroupBValues}
+          onChange={setToggleGroupBValues}>
+          <View style={[a.gap_md]}>
+            <Toggle.Item name="a" label="Click me">
+              <Toggle.Checkbox />
+              <Toggle.Label>Click me</Toggle.Label>
+            </Toggle.Item>
+            <Toggle.Item name="b" label="Click me">
+              <Toggle.Checkbox />
+              <Toggle.Label>Click me</Toggle.Label>
+            </Toggle.Item>
+            <Toggle.Item name="c" label="Click me">
+              <Toggle.Checkbox />
+              <Toggle.Label>Click me</Toggle.Label>
+            </Toggle.Item>
+            <Toggle.Item name="d" disabled label="Click me">
+              <Toggle.Checkbox />
+              <Toggle.Label>Click me</Toggle.Label>
+            </Toggle.Item>
+            <Toggle.Item name="e" isInvalid label="Click me">
+              <Toggle.Checkbox />
+              <Toggle.Label>Click me</Toggle.Label>
+            </Toggle.Item>
+          </View>
+        </Toggle.Group>
+
+        <Toggle.Group
+          label="Toggle"
+          type="radio"
+          values={toggleGroupCValues}
+          onChange={setToggleGroupCValues}>
+          <View style={[a.gap_md]}>
+            <Toggle.Item name="a" label="Click me">
+              <Toggle.Radio />
+              <Toggle.Label>Click me</Toggle.Label>
+            </Toggle.Item>
+            <Toggle.Item name="b" label="Click me">
+              <Toggle.Radio />
+              <Toggle.Label>Click me</Toggle.Label>
+            </Toggle.Item>
+            <Toggle.Item name="c" label="Click me">
+              <Toggle.Radio />
+              <Toggle.Label>Click me</Toggle.Label>
+            </Toggle.Item>
+            <Toggle.Item name="d" disabled label="Click me">
+              <Toggle.Radio />
+              <Toggle.Label>Click me</Toggle.Label>
+            </Toggle.Item>
+            <Toggle.Item name="e" isInvalid label="Click me">
+              <Toggle.Radio />
+              <Toggle.Label>Click me</Toggle.Label>
+            </Toggle.Item>
+          </View>
+        </Toggle.Group>
+      </View>
+
+      <Button
+        variant="gradient"
+        color="gradient_nordic"
+        size="small"
+        label="Reset all toggles"
+        onPress={() => {
+          setToggleGroupAValues(['a'])
+          setToggleGroupBValues(['a', 'b'])
+          setToggleGroupCValues(['a'])
+        }}>
+        Reset all toggles
+      </Button>
+
+      <View style={[a.gap_md, a.align_start, a.w_full]}>
+        <H3>ToggleButton</H3>
+
+        <ToggleButton.Group
+          label="Preferences"
+          values={toggleGroupDValues}
+          onChange={setToggleGroupDValues}>
+          <ToggleButton.Button name="hide" label="Hide">
+            Hide
+          </ToggleButton.Button>
+          <ToggleButton.Button name="warn" label="Warn">
+            Warn
+          </ToggleButton.Button>
+          <ToggleButton.Button name="show" label="Show">
+            Show
+          </ToggleButton.Button>
+        </ToggleButton.Group>
+      </View>
+    </View>
+  )
+}
diff --git a/src/view/screens/Storybook/Icons.tsx b/src/view/screens/Storybook/Icons.tsx
new file mode 100644
index 000000000..73466e077
--- /dev/null
+++ b/src/view/screens/Storybook/Icons.tsx
@@ -0,0 +1,41 @@
+import React from 'react'
+import {View} from 'react-native'
+
+import {atoms as a, useTheme} from '#/alf'
+import {H1} from '#/components/Typography'
+import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
+import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRight} from '#/components/icons/ArrowTopRight'
+import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
+
+export function Icons() {
+  const t = useTheme()
+  return (
+    <View style={[a.gap_md]}>
+      <H1>Icons</H1>
+
+      <View style={[a.flex_row, a.gap_xl]}>
+        <Globe size="xs" fill={t.atoms.text.color} />
+        <Globe size="sm" fill={t.atoms.text.color} />
+        <Globe size="md" fill={t.atoms.text.color} />
+        <Globe size="lg" fill={t.atoms.text.color} />
+        <Globe size="xl" fill={t.atoms.text.color} />
+      </View>
+
+      <View style={[a.flex_row, a.gap_xl]}>
+        <ArrowTopRight size="xs" fill={t.atoms.text.color} />
+        <ArrowTopRight size="sm" fill={t.atoms.text.color} />
+        <ArrowTopRight size="md" fill={t.atoms.text.color} />
+        <ArrowTopRight size="lg" fill={t.atoms.text.color} />
+        <ArrowTopRight size="xl" fill={t.atoms.text.color} />
+      </View>
+
+      <View style={[a.flex_row, a.gap_xl]}>
+        <CalendarDays size="xs" fill={t.atoms.text.color} />
+        <CalendarDays size="sm" fill={t.atoms.text.color} />
+        <CalendarDays size="md" fill={t.atoms.text.color} />
+        <CalendarDays size="lg" fill={t.atoms.text.color} />
+        <CalendarDays size="xl" fill={t.atoms.text.color} />
+      </View>
+    </View>
+  )
+}
diff --git a/src/view/screens/Storybook/Links.tsx b/src/view/screens/Storybook/Links.tsx
new file mode 100644
index 000000000..c3b1c0e0f
--- /dev/null
+++ b/src/view/screens/Storybook/Links.tsx
@@ -0,0 +1,48 @@
+import React from 'react'
+import {View} from 'react-native'
+
+import {atoms as a} from '#/alf'
+import {ButtonText} from '#/components/Button'
+import {Link} from '#/components/Link'
+import {H1, H3} from '#/components/Typography'
+
+export function Links() {
+  return (
+    <View style={[a.gap_md, a.align_start]}>
+      <H1>Links</H1>
+
+      <View style={[a.gap_md, a.align_start]}>
+        <Link
+          to="https://blueskyweb.xyz"
+          warnOnMismatchingTextChild
+          style={[a.text_md]}>
+          External
+        </Link>
+        <Link to="https://blueskyweb.xyz" style={[a.text_md]}>
+          <H3>External with custom children</H3>
+        </Link>
+        <Link
+          to="https://blueskyweb.xyz"
+          warnOnMismatchingTextChild
+          style={[a.text_lg]}>
+          https://blueskyweb.xyz
+        </Link>
+        <Link
+          to="https://bsky.app/profile/bsky.app"
+          warnOnMismatchingTextChild
+          style={[a.text_md]}>
+          Internal
+        </Link>
+
+        <Link
+          variant="solid"
+          color="primary"
+          size="large"
+          label="View @bsky.app's profile"
+          to="https://bsky.app/profile/bsky.app">
+          <ButtonText>Link as a button</ButtonText>
+        </Link>
+      </View>
+    </View>
+  )
+}
diff --git a/src/view/screens/Storybook/Palette.tsx b/src/view/screens/Storybook/Palette.tsx
new file mode 100644
index 000000000..b521fe860
--- /dev/null
+++ b/src/view/screens/Storybook/Palette.tsx
@@ -0,0 +1,336 @@
+import React from 'react'
+import {View} from 'react-native'
+
+import * as tokens from '#/alf/tokens'
+import {atoms as a} from '#/alf'
+
+export function Palette() {
+  return (
+    <View style={[a.gap_md]}>
+      <View style={[a.flex_row, a.gap_md]}>
+        <View
+          style={[a.flex_1, {height: 60, backgroundColor: tokens.color.gray_0}]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.gray_25},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.gray_50},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.gray_100},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.gray_200},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.gray_300},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.gray_400},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.gray_500},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.gray_600},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.gray_700},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.gray_800},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.gray_900},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.gray_950},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.gray_975},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.gray_1000},
+          ]}
+        />
+      </View>
+
+      <View style={[a.flex_row, a.gap_md]}>
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.blue_25},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.blue_50},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.blue_100},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.blue_200},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.blue_300},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.blue_400},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.blue_500},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.blue_600},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.blue_700},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.blue_800},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.blue_900},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.blue_950},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.blue_975},
+          ]}
+        />
+      </View>
+      <View style={[a.flex_row, a.gap_md]}>
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.green_25},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.green_50},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.green_100},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.green_200},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.green_300},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.green_400},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.green_500},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.green_600},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.green_700},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.green_800},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.green_900},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.green_950},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.green_975},
+          ]}
+        />
+      </View>
+      <View style={[a.flex_row, a.gap_md]}>
+        <View
+          style={[a.flex_1, {height: 60, backgroundColor: tokens.color.red_25}]}
+        />
+        <View
+          style={[a.flex_1, {height: 60, backgroundColor: tokens.color.red_50}]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.red_100},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.red_200},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.red_300},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.red_400},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.red_500},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.red_600},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.red_700},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.red_800},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.red_900},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.red_950},
+          ]}
+        />
+        <View
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: tokens.color.red_975},
+          ]}
+        />
+      </View>
+    </View>
+  )
+}
diff --git a/src/view/screens/Storybook/Shadows.tsx b/src/view/screens/Storybook/Shadows.tsx
new file mode 100644
index 000000000..f92112395
--- /dev/null
+++ b/src/view/screens/Storybook/Shadows.tsx
@@ -0,0 +1,53 @@
+import React from 'react'
+import {View} from 'react-native'
+
+import {atoms as a, useTheme} from '#/alf'
+import {H1, Text} from '#/components/Typography'
+
+export function Shadows() {
+  const t = useTheme()
+
+  return (
+    <View style={[a.gap_md]}>
+      <H1>Shadows</H1>
+
+      <View style={[a.flex_row, a.gap_5xl]}>
+        <View
+          style={[
+            a.flex_1,
+            a.justify_center,
+            a.px_lg,
+            a.py_2xl,
+            t.atoms.bg,
+            t.atoms.shadow_sm,
+          ]}>
+          <Text>shadow_sm</Text>
+        </View>
+
+        <View
+          style={[
+            a.flex_1,
+            a.justify_center,
+            a.px_lg,
+            a.py_2xl,
+            t.atoms.bg,
+            t.atoms.shadow_md,
+          ]}>
+          <Text>shadow_md</Text>
+        </View>
+
+        <View
+          style={[
+            a.flex_1,
+            a.justify_center,
+            a.px_lg,
+            a.py_2xl,
+            t.atoms.bg,
+            t.atoms.shadow_lg,
+          ]}>
+          <Text>shadow_lg</Text>
+        </View>
+      </View>
+    </View>
+  )
+}
diff --git a/src/view/screens/Storybook/Spacing.tsx b/src/view/screens/Storybook/Spacing.tsx
new file mode 100644
index 000000000..d7faf93a8
--- /dev/null
+++ b/src/view/screens/Storybook/Spacing.tsx
@@ -0,0 +1,64 @@
+import React from 'react'
+import {View} from 'react-native'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Text, H1} from '#/components/Typography'
+
+export function Spacing() {
+  const t = useTheme()
+  return (
+    <View style={[a.gap_md]}>
+      <H1>Spacing</H1>
+
+      <View style={[a.flex_row, a.align_center]}>
+        <Text style={{width: 80}}>2xs (2px)</Text>
+        <View style={[a.flex_1, a.pt_2xs, t.atoms.bg_contrast_300]} />
+      </View>
+
+      <View style={[a.flex_row, a.align_center]}>
+        <Text style={{width: 80}}>xs (4px)</Text>
+        <View style={[a.flex_1, a.pt_xs, t.atoms.bg_contrast_300]} />
+      </View>
+
+      <View style={[a.flex_row, a.align_center]}>
+        <Text style={{width: 80}}>sm (8px)</Text>
+        <View style={[a.flex_1, a.pt_sm, t.atoms.bg_contrast_300]} />
+      </View>
+
+      <View style={[a.flex_row, a.align_center]}>
+        <Text style={{width: 80}}>md (12px)</Text>
+        <View style={[a.flex_1, a.pt_md, t.atoms.bg_contrast_300]} />
+      </View>
+
+      <View style={[a.flex_row, a.align_center]}>
+        <Text style={{width: 80}}>lg (16px)</Text>
+        <View style={[a.flex_1, a.pt_lg, t.atoms.bg_contrast_300]} />
+      </View>
+
+      <View style={[a.flex_row, a.align_center]}>
+        <Text style={{width: 80}}>xl (20px)</Text>
+        <View style={[a.flex_1, a.pt_xl, t.atoms.bg_contrast_300]} />
+      </View>
+
+      <View style={[a.flex_row, a.align_center]}>
+        <Text style={{width: 80}}>2xl (24px)</Text>
+        <View style={[a.flex_1, a.pt_2xl, t.atoms.bg_contrast_300]} />
+      </View>
+
+      <View style={[a.flex_row, a.align_center]}>
+        <Text style={{width: 80}}>3xl (28px)</Text>
+        <View style={[a.flex_1, a.pt_3xl, t.atoms.bg_contrast_300]} />
+      </View>
+
+      <View style={[a.flex_row, a.align_center]}>
+        <Text style={{width: 80}}>4xl (32px)</Text>
+        <View style={[a.flex_1, a.pt_4xl, t.atoms.bg_contrast_300]} />
+      </View>
+
+      <View style={[a.flex_row, a.align_center]}>
+        <Text style={{width: 80}}>5xl (40px)</Text>
+        <View style={[a.flex_1, a.pt_5xl, t.atoms.bg_contrast_300]} />
+      </View>
+    </View>
+  )
+}
diff --git a/src/view/screens/Storybook/Theming.tsx b/src/view/screens/Storybook/Theming.tsx
new file mode 100644
index 000000000..a05443473
--- /dev/null
+++ b/src/view/screens/Storybook/Theming.tsx
@@ -0,0 +1,56 @@
+import React from 'react'
+import {View} from 'react-native'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
+import {Palette} from './Palette'
+
+export function Theming() {
+  const t = useTheme()
+
+  return (
+    <View style={[t.atoms.bg, a.gap_lg, a.p_xl]}>
+      <Palette />
+
+      <Text style={[a.font_bold, a.pt_xl, a.px_md]}>theme.atoms.text</Text>
+
+      <View style={[a.flex_1, t.atoms.border, a.border_t]} />
+      <Text style={[a.font_bold, t.atoms.text_contrast_600, a.px_md]}>
+        theme.atoms.text_contrast_600
+      </Text>
+
+      <View style={[a.flex_1, t.atoms.border, a.border_t]} />
+      <Text style={[a.font_bold, t.atoms.text_contrast_500, a.px_md]}>
+        theme.atoms.text_contrast_500
+      </Text>
+
+      <View style={[a.flex_1, t.atoms.border, a.border_t]} />
+      <Text style={[a.font_bold, t.atoms.text_contrast_400, a.px_md]}>
+        theme.atoms.text_contrast_400
+      </Text>
+
+      <View style={[a.flex_1, t.atoms.border_contrast, a.border_t]} />
+
+      <View style={[a.w_full, a.gap_md]}>
+        <View style={[t.atoms.bg, a.justify_center, a.p_md]}>
+          <Text>theme.atoms.bg</Text>
+        </View>
+        <View style={[t.atoms.bg_contrast_25, a.justify_center, a.p_md]}>
+          <Text>theme.atoms.bg_contrast_25</Text>
+        </View>
+        <View style={[t.atoms.bg_contrast_50, a.justify_center, a.p_md]}>
+          <Text>theme.atoms.bg_contrast_50</Text>
+        </View>
+        <View style={[t.atoms.bg_contrast_100, a.justify_center, a.p_md]}>
+          <Text>theme.atoms.bg_contrast_100</Text>
+        </View>
+        <View style={[t.atoms.bg_contrast_200, a.justify_center, a.p_md]}>
+          <Text>theme.atoms.bg_contrast_200</Text>
+        </View>
+        <View style={[t.atoms.bg_contrast_300, a.justify_center, a.p_md]}>
+          <Text>theme.atoms.bg_contrast_300</Text>
+        </View>
+      </View>
+    </View>
+  )
+}
diff --git a/src/view/screens/Storybook/Typography.tsx b/src/view/screens/Storybook/Typography.tsx
new file mode 100644
index 000000000..2e1f04a66
--- /dev/null
+++ b/src/view/screens/Storybook/Typography.tsx
@@ -0,0 +1,30 @@
+import React from 'react'
+import {View} from 'react-native'
+
+import {atoms as a} from '#/alf'
+import {Text, H1, H2, H3, H4, H5, H6, P} from '#/components/Typography'
+
+export function Typography() {
+  return (
+    <View style={[a.gap_md]}>
+      <H1>H1 Heading</H1>
+      <H2>H2 Heading</H2>
+      <H3>H3 Heading</H3>
+      <H4>H4 Heading</H4>
+      <H5>H5 Heading</H5>
+      <H6>H6 Heading</H6>
+      <P>P Paragraph</P>
+
+      <Text style={[a.text_5xl]}>atoms.text_5xl</Text>
+      <Text style={[a.text_4xl]}>atoms.text_4xl</Text>
+      <Text style={[a.text_3xl]}>atoms.text_3xl</Text>
+      <Text style={[a.text_2xl]}>atoms.text_2xl</Text>
+      <Text style={[a.text_xl]}>atoms.text_xl</Text>
+      <Text style={[a.text_lg]}>atoms.text_lg</Text>
+      <Text style={[a.text_md]}>atoms.text_md</Text>
+      <Text style={[a.text_sm]}>atoms.text_sm</Text>
+      <Text style={[a.text_xs]}>atoms.text_xs</Text>
+      <Text style={[a.text_2xs]}>atoms.text_2xs</Text>
+    </View>
+  )
+}
diff --git a/src/view/screens/Storybook/index.tsx b/src/view/screens/Storybook/index.tsx
new file mode 100644
index 000000000..d8898f20e
--- /dev/null
+++ b/src/view/screens/Storybook/index.tsx
@@ -0,0 +1,78 @@
+import React from 'react'
+import {View} from 'react-native'
+import {CenteredView, ScrollView} from '#/view/com/util/Views'
+
+import {atoms as a, useTheme, ThemeProvider} from '#/alf'
+import {useSetColorMode} from '#/state/shell'
+import {Button} from '#/components/Button'
+
+import {Theming} from './Theming'
+import {Typography} from './Typography'
+import {Spacing} from './Spacing'
+import {Buttons} from './Buttons'
+import {Links} from './Links'
+import {Forms} from './Forms'
+import {Dialogs} from './Dialogs'
+import {Breakpoints} from './Breakpoints'
+import {Shadows} from './Shadows'
+import {Icons} from './Icons'
+
+export function Storybook() {
+  const t = useTheme()
+  const setColorMode = useSetColorMode()
+
+  return (
+    <ScrollView>
+      <CenteredView style={[t.atoms.bg]}>
+        <View style={[a.p_xl, a.gap_5xl, {paddingBottom: 200}]}>
+          <View style={[a.flex_row, a.align_start, a.gap_md]}>
+            <Button
+              variant="outline"
+              color="primary"
+              size="small"
+              label='Set theme to "system"'
+              onPress={() => setColorMode('system')}>
+              System
+            </Button>
+            <Button
+              variant="solid"
+              color="secondary"
+              size="small"
+              label='Set theme to "system"'
+              onPress={() => setColorMode('light')}>
+              Light
+            </Button>
+            <Button
+              variant="solid"
+              color="secondary"
+              size="small"
+              label='Set theme to "system"'
+              onPress={() => setColorMode('dark')}>
+              Dark
+            </Button>
+          </View>
+
+          <ThemeProvider theme="light">
+            <Theming />
+          </ThemeProvider>
+          <ThemeProvider theme="dim">
+            <Theming />
+          </ThemeProvider>
+          <ThemeProvider theme="dark">
+            <Theming />
+          </ThemeProvider>
+
+          <Typography />
+          <Spacing />
+          <Shadows />
+          <Buttons />
+          <Icons />
+          <Links />
+          <Forms />
+          <Dialogs />
+          <Breakpoints />
+        </View>
+      </CenteredView>
+    </ScrollView>
+  )
+}
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx
index 51c03ae3d..5320aebfc 100644
--- a/src/view/shell/index.tsx
+++ b/src/view/shell/index.tsx
@@ -28,6 +28,7 @@ import {isAndroid} from 'platform/detection'
 import {useSession} from '#/state/session'
 import {useCloseAnyActiveElement} from '#/state/util'
 import * as notifications from 'lib/notifications/notifications'
+import {Outlet as PortalOutlet} from '#/components/Portal'
 
 function ShellInner() {
   const isDrawerOpen = useIsDrawerOpen()
@@ -94,6 +95,7 @@ function ShellInner() {
       </View>
       <Composer winHeight={winDim.height} />
       <ModalsContainer />
+      <PortalOutlet />
       <Lightbox />
     </>
   )
diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx
index 20bc0dff1..1ada883c9 100644
--- a/src/view/shell/index.web.tsx
+++ b/src/view/shell/index.web.tsx
@@ -15,6 +15,7 @@ import {useAuxClick} from 'lib/hooks/useAuxClick'
 import {t} from '@lingui/macro'
 import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
 import {useCloseAllActiveElements} from '#/state/util'
+import {Outlet as PortalOutlet} from '#/components/Portal'
 
 function ShellInner() {
   const isDrawerOpen = useIsDrawerOpen()
@@ -41,6 +42,7 @@ function ShellInner() {
       </View>
       <Composer winHeight={0} />
       <ModalsContainer />
+      <PortalOutlet />
       <Lightbox />
       {!isDesktop && isDrawerOpen && (
         <TouchableOpacity
diff --git a/web/index.html b/web/index.html
index 5a56e5f76..46b20be1f 100644
--- a/web/index.html
+++ b/web/index.html
@@ -43,6 +43,25 @@
         height: calc(100% + env(safe-area-inset-top));
       }
 
+      /* Remove autofill styles on Webkit */
+      input:-webkit-autofill,
+      input:-webkit-autofill:hover, 
+      input:-webkit-autofill:focus,
+      textarea:-webkit-autofill,
+      textarea:-webkit-autofill:hover,
+      textarea:-webkit-autofill:focus,
+      select:-webkit-autofill,
+      select:-webkit-autofill:hover,
+      select:-webkit-autofill:focus {
+        border: 0;
+        -webkit-text-fill-color: transparent;
+        -webkit-box-shadow: none;
+      }
+      /* Force left-align date/time inputs on iOS mobile */
+      input::-webkit-date-and-time-value {
+        text-align: left;
+      }
+
       /* Color theming */
       :root {
         --text: black;
diff --git a/yarn.lock b/yarn.lock
index 760e0b331..cc3c36031 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6966,6 +6966,31 @@
     "@svgr/plugin-svgo" "^5.5.0"
     loader-utils "^2.0.0"
 
+"@tamagui/compose-refs@1.84.1":
+  version "1.84.1"
+  resolved "https://registry.yarnpkg.com/@tamagui/compose-refs/-/compose-refs-1.84.1.tgz#244735edc3ac2e617389297f005d5bc25872465f"
+  integrity sha512-oZ0rUmQABlGm/QKQITxAW9WLV3qjyq1ehgoWcZVmtc1Kc/hkFQe2J+wRQV726CmTAnuUgUXi3eoNMwBVoZksfQ==
+
+"@tamagui/constants@1.84.1":
+  version "1.84.1"
+  resolved "https://registry.yarnpkg.com/@tamagui/constants/-/constants-1.84.1.tgz#62e41837dbe844d14e255f3eea9c2583044d2509"
+  integrity sha512-QmvyCqtEIugqXutQI35GJQ1hlpSapYCdOHx9QlgsOWjAY34pu55MaY/tDrQeQ0AUmI/qx30vy7TsCJxB4QFEoQ==
+
+"@tamagui/focus-scope@^1.84.1":
+  version "1.84.1"
+  resolved "https://registry.yarnpkg.com/@tamagui/focus-scope/-/focus-scope-1.84.1.tgz#e9f061184048c75f87da023f54b9c5abccdd460d"
+  integrity sha512-0E1Wc3jmKhafETfH1dUuJYmGK1bDNA/9TySbOeTjTToxUoL3V0G2W5JSwSMCDqR1Bl+xrGlGwzXTUhouw8qSog==
+  dependencies:
+    "@tamagui/compose-refs" "1.84.1"
+    "@tamagui/use-event" "1.84.1"
+
+"@tamagui/use-event@1.84.1":
+  version "1.84.1"
+  resolved "https://registry.yarnpkg.com/@tamagui/use-event/-/use-event-1.84.1.tgz#a095a1bde9c40c4a397226c57c3fa32f6018f504"
+  integrity sha512-U88WCxvMz7ZSfMFMJEFbG3tJjK/Lf+PHlmtYvlx1V+YiqRBoj5+milzoM8PclENn5vZMiJW0ozYRgzI/cdE7Eg==
+  dependencies:
+    "@tamagui/constants" "1.84.1"
+
 "@tanstack/query-core@5.8.1":
   version "5.8.1"
   resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.8.1.tgz#5215a028370d9b2f32e83787a0ea119e2f977996"