about summary refs log tree commit diff
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2024-01-25 22:22:40 -0600
committerGitHub <noreply@github.com>2024-01-25 20:22:40 -0800
commit3371038f7d8b740f2415d3a54dc99b69e0598042 (patch)
tree9ef008e132f268ce6a8a23765ab34fda71a4bedc
parent5443503593a67cc7ff6e081ef9b1fe66ea0cbe0d (diff)
downloadvoidsky-3371038f7d8b740f2415d3a54dc99b69e0598042.tar.zst
New Onboarding (#2596)
* Add round and square buttons

* Allow some style for buttons, add icons

* Change text selection color

* Center button text, whoops

* Outer layout, some primitive updates

* WIP

* onboarding feed prefs (#2590)

* add `style` to toggle label to modify text style

* Revert "add `style` to toggle label to modify text style"

This reverts commit 8f4b517b8585ca64a4bf44f6cb40ac070ece8932.

* following feed prefs

* remove unnecessary memo

* reusable divider component

* org imports

* add finished screen

* Theme SelectedAccountCard

* Require at least 3 interests

* Placeholder save logic

* WIP algo feeds

* Improve lineHeight handling, add RichText, improve Link by adding InlineLink

* Inherit lineHeight in heading comps

* Algo feeds mostly good

* Topical feeds ish

* Layout cleanup

* Improve button styles

* moderation prefs for onboarding (#2594)

* WIP algo feeds

* modify controlalbelgroup typing for easy .map()

* adjust padding on button

* add moderation screen

* add moderation screen

* add moderation screen

---------

Co-authored-by: Eric Bailey <git@esb.lol>

* Fix toggle button styles

* A11y props on outer portal

* Put it all on red

* New data shape

* Handle mock data

* Bulk write (not yet)

* Remove interests validation

* Clean up interests

* i18n layout and first step

* Clean up suggested follows screen

* Clean up following step

* Clean up algo feeds step

* Clean up topical feeds

* Add skeleton for feed card

* WIP moderation step

* cleanup moderation styles (#2605)

* cleanup moderation styles

* fix(?) toggle button group styles

* adjust toggle to fit any screen

* Some more cleanup

* Icons

* ToggleButton tweaks

* Reset

* Hook up data

* Better suggestions

* Bulk write

* Some logging

* Use new api

* Concat topical feeds

* Metrics

* Disable links in RichText, feedcards

* Tweak primary feed cards

* Update metrics

* Fix layout shift

* Fix ToggleButton again, whoops

* Error state

* Bump api package, ensure interests are saved

* Better fix for autofill

* i18n, button positions

* Remove unused export

* Add default prefs object

* Fix overflow in user cards

* Add 2 lines of bios to suggested accounts cards

* Nits

* Don't resolve facets by default

* Update storybook

* Disable flag for now

* Remove age dialog from moderations step

* Improvements and tweaks to new onboarding

---------

Co-authored-by: Hailey <153161762+haileyok@users.noreply.github.com>
Co-authored-by: Paul Frazee <pfrazee@gmail.com>
-rw-r--r--assets/icons/arrowRotateCounterClockwise_stroke2_corner0_rounded.svg1
-rw-r--r--assets/icons/at_stroke2_corner0_rounded.svg1
-rw-r--r--assets/icons/check_stroke2_corner0_rounded.svg1
-rw-r--r--assets/icons/chevronLeft_stroke2_corner0_rounded.svg1
-rw-r--r--assets/icons/chevronRight_stroke2_corner0_rounded.svg1
-rw-r--r--assets/icons/circleInfo_stroke2_corner0_rounded.svg1
-rw-r--r--assets/icons/emojiSad_stroke2_corner0_rounded.svg1
-rw-r--r--assets/icons/eyeSlash_stroke2_corner0_rounded.svg1
-rw-r--r--assets/icons/filterTimeline_stroke2_corner0_rounded.svg1
-rw-r--r--assets/icons/growth_stroke2_corner0_rounded.svg1
-rw-r--r--assets/icons/hashtag_stroke2_corner0_rounded.svg1
-rw-r--r--assets/icons/listMagnifyingGlass_stroke2_corner0_rounded.svg1
-rw-r--r--assets/icons/listSparkle_stroke2_corner0_rounded.svg1
-rw-r--r--assets/icons/loader_stroke2_corner0_rounded.svg1
-rw-r--r--assets/icons/news2_stroke2_corner0_rounded.svg1
-rw-r--r--assets/icons/plusLarge_stroke2_corner0_rounded.svg1
-rw-r--r--assets/icons/trending2_stroke2_corner2_rounded.svg1
-rw-r--r--bskyweb/templates/base.html41
-rw-r--r--src/alf/atoms.ts16
-rw-r--r--src/alf/index.tsx1
-rw-r--r--src/alf/tokens.ts8
-rw-r--r--src/alf/types.ts10
-rw-r--r--src/components/Button.tsx75
-rw-r--r--src/components/Divider.tsx10
-rw-r--r--src/components/Link.tsx182
-rw-r--r--src/components/Portal.tsx87
-rw-r--r--src/components/RichText.tsx131
-rw-r--r--src/components/Typography.tsx96
-rw-r--r--src/components/forms/TextField.tsx2
-rw-r--r--src/components/forms/Toggle.tsx8
-rw-r--r--src/components/forms/ToggleButton.tsx8
-rw-r--r--src/components/icons/ArrowRotateCounterClockwise.tsx6
-rw-r--r--src/components/icons/At.tsx5
-rw-r--r--src/components/icons/Check.tsx5
-rw-r--r--src/components/icons/Chevron.tsx9
-rw-r--r--src/components/icons/CircleInfo.tsx5
-rw-r--r--src/components/icons/Emoji.tsx5
-rw-r--r--src/components/icons/EyeSlash.tsx5
-rw-r--r--src/components/icons/FilterTimeline.tsx5
-rw-r--r--src/components/icons/Growth.tsx5
-rw-r--r--src/components/icons/Hashtag.tsx5
-rw-r--r--src/components/icons/ListMagnifyingGlass.tsx5
-rw-r--r--src/components/icons/ListSparkle.tsx5
-rw-r--r--src/components/icons/News2.tsx5
-rw-r--r--src/components/icons/Plus.tsx5
-rw-r--r--src/components/icons/Trending2.tsx5
-rw-r--r--src/lib/analytics/types.ts32
-rw-r--r--src/lib/build-flags.ts1
-rw-r--r--src/screens/Onboarding/IconCircle.tsx51
-rw-r--r--src/screens/Onboarding/Layout.tsx231
-rw-r--r--src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx378
-rw-r--r--src/screens/Onboarding/StepAlgoFeeds/index.tsx160
-rw-r--r--src/screens/Onboarding/StepFinished.tsx158
-rw-r--r--src/screens/Onboarding/StepFollowingFeed.tsx160
-rw-r--r--src/screens/Onboarding/StepInterests/InterestButton.tsx79
-rw-r--r--src/screens/Onboarding/StepInterests/data.ts36
-rw-r--r--src/screens/Onboarding/StepInterests/index.tsx260
-rw-r--r--src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx135
-rw-r--r--src/screens/Onboarding/StepModeration/ModerationOption.tsx85
-rw-r--r--src/screens/Onboarding/StepModeration/index.tsx91
-rw-r--r--src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx188
-rw-r--r--src/screens/Onboarding/StepSuggestedAccounts/index.tsx198
-rw-r--r--src/screens/Onboarding/StepTopicalFeeds.tsx113
-rw-r--r--src/screens/Onboarding/index.tsx38
-rw-r--r--src/screens/Onboarding/state.ts201
-rw-r--r--src/screens/Onboarding/util.ts112
-rw-r--r--src/state/queries/preferences/const.ts1
-rw-r--r--src/state/queries/preferences/types.ts19
-rw-r--r--src/state/queries/profile.ts12
-rw-r--r--src/view/screens/Storybook/Buttons.tsx89
-rw-r--r--src/view/screens/Storybook/Forms.tsx17
-rw-r--r--src/view/screens/Storybook/Links.tsx46
-rw-r--r--src/view/screens/Storybook/Typography.tsx11
-rw-r--r--src/view/shell/createNativeStackNavigatorWithAuth.tsx9
-rw-r--r--web/index.html47
75 files changed, 3517 insertions, 213 deletions
diff --git a/assets/icons/arrowRotateCounterClockwise_stroke2_corner0_rounded.svg b/assets/icons/arrowRotateCounterClockwise_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..955b3dbc3
--- /dev/null
+++ b/assets/icons/arrowRotateCounterClockwise_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="M5 3a1 1 0 0 1 1 1v1.423c.498-.46 1.02-.869 1.58-1.213C8.863 3.423 10.302 3 12.028 3a9 9 0 1 1-8.487 12 1 1 0 0 1 1.885-.667A7 7 0 1 0 12.028 5c-1.37 0-2.444.327-3.402.915-.474.29-.93.652-1.383 1.085H9a1 1 0 0 1 0 2H5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Z" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/at_stroke2_corner0_rounded.svg b/assets/icons/at_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..8d30d7c8c
--- /dev/null
+++ b/assets/icons/at_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="M12 4a8 8 0 1 0 4.21 14.804 1 1 0 0 1 1.054 1.7A9.958 9.958 0 0 1 12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10c0 1.104-.27 2.31-.949 3.243-.716.984-1.849 1.6-3.331 1.465a4.207 4.207 0 0 1-2.93-1.585c-.94 1.21-2.388 1.94-3.985 1.715-2.53-.356-4.04-2.91-3.682-5.458.358-2.547 2.514-4.586 5.044-4.23.905.127 1.68.536 2.286 1.126a1 1 0 0 1 1.964.368l-.515 3.545v.002a2.222 2.222 0 0 0 1.999 2.526c.75.068 1.212-.21 1.533-.65.358-.493.566-1.245.566-2.067a8 8 0 0 0-8-8Zm-.112 5.13c-1.195-.168-2.544.819-2.784 2.529-.24 1.71.784 3.03 1.98 3.198 1.195.168 2.543-.819 2.784-2.529.24-1.71-.784-3.03-1.98-3.198Z" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/check_stroke2_corner0_rounded.svg b/assets/icons/check_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..b336a518f
--- /dev/null
+++ b/assets/icons/check_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="M21.59 3.193a1 1 0 0 1 .217 1.397l-11.706 16a1 1 0 0 1-1.429.193l-6.294-5a1 1 0 1 1 1.244-1.566l5.48 4.353 11.09-15.16a1 1 0 0 1 1.398-.217Z" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/chevronLeft_stroke2_corner0_rounded.svg b/assets/icons/chevronLeft_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..d9a8660f7
--- /dev/null
+++ b/assets/icons/chevronLeft_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="M15.707 3.293a1 1 0 0 1 0 1.414L8.414 12l7.293 7.293a1 1 0 0 1-1.414 1.414l-8-8a1 1 0 0 1 0-1.414l8-8a1 1 0 0 1 1.414 0Z" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/chevronRight_stroke2_corner0_rounded.svg b/assets/icons/chevronRight_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..b57fd0398
--- /dev/null
+++ b/assets/icons/chevronRight_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="M8.293 3.293a1 1 0 0 1 1.414 0l8 8a1 1 0 0 1 0 1.414l-8 8a1 1 0 0 1-1.414-1.414L15.586 12 8.293 4.707a1 1 0 0 1 0-1.414Z" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/circleInfo_stroke2_corner0_rounded.svg b/assets/icons/circleInfo_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..926d4b391
--- /dev/null
+++ b/assets/icons/circleInfo_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="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm8-1a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-4a1 1 0 0 1-1-1Zm1-3a1 1 0 1 0 2 0 1 1 0 0 0-2 0Z" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/emojiSad_stroke2_corner0_rounded.svg b/assets/icons/emojiSad_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..0a5a43cd0
--- /dev/null
+++ b/assets/icons/emojiSad_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="M6.343 6.343a8 8 0 1 1 11.314 11.314A8 8 0 0 1 6.343 6.343ZM19.071 4.93c-3.905-3.905-10.237-3.905-14.142 0-3.905 3.905-3.905 10.237 0 14.142 3.905 3.905 10.237 3.905 14.142 0 3.905-3.905 3.905-10.237 0-14.142Zm-3.537 9.535a5 5 0 0 0-7.07 0 1 1 0 1 0 1.413 1.415 3 3 0 0 1 4.243 0 1 1 0 0 0 1.414-1.415ZM16 9.5c0 .828-.56 1.5-1.25 1.5s-1.25-.672-1.25-1.5.56-1.5 1.25-1.5S16 8.672 16 9.5ZM9.25 11c.69 0 1.25-.672 1.25-1.5S9.94 8 9.25 8 8 8.672 8 9.5 8.56 11 9.25 11Z" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/eyeSlash_stroke2_corner0_rounded.svg b/assets/icons/eyeSlash_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..f11bdd937
--- /dev/null
+++ b/assets/icons/eyeSlash_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="M2.293 2.293a1 1 0 0 1 1.414 0L7.335 5.92l.03.03 3.22 3.222 4.243 4.242 3.22 3.22.03.03 3.63 3.629a1 1 0 0 1-1.415 1.414l-3.09-3.09c-2.65 1.478-5.625 1.778-8.421.869-3.039-.987-5.779-3.37-7.67-7.027a1 1 0 0 1 0-.918c1.086-2.1 2.452-3.78 3.996-5.019L2.293 3.707a1 1 0 0 1 0-1.414Zm4.24 5.654 2.021 2.021a4 4 0 0 0 5.478 5.478l1.688 1.688c-2.042.982-4.246 1.124-6.32.45-2.34-.76-4.594-2.586-6.265-5.584.97-1.739 2.135-3.083 3.398-4.053Zm3.535 3.535 2.45 2.45a2 2 0 0 1-2.45-2.45Zm.81-5.405c3.573-.49 7.45 1.369 9.987 5.923a14.797 14.797 0 0 1-1.347 2.02 1 1 0 1 0 1.564 1.247 17.078 17.078 0 0 0 1.806-2.808 1 1 0 0 0 0-.918c-2.833-5.479-7.584-8.088-12.281-7.446a1 1 0 0 0 .271 1.982Z" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/filterTimeline_stroke2_corner0_rounded.svg b/assets/icons/filterTimeline_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..459b9212a
--- /dev/null
+++ b/assets/icons/filterTimeline_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="M7.002 5a1 1 0 0 0-2 0v11.587l-1.295-1.294a1 1 0 0 0-1.414 1.414l3.002 3a1 1 0 0 0 1.414 0l2.998-3a1 1 0 0 0-1.414-1.414l-1.291 1.292V5ZM16 16a1 1 0 1 0 0 2h4a1 1 0 1 0 0-2h-4Zm-3-4a1 1 0 0 1 1-1h6a1 1 0 1 1 0 2h-6a1 1 0 0 1-1-1Zm-1-6a1 1 0 1 0 0 2h8a1 1 0 1 0 0-2h-8Z" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/growth_stroke2_corner0_rounded.svg b/assets/icons/growth_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..ec9083fb1
--- /dev/null
+++ b/assets/icons/growth_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="M3 4a1 1 0 0 1 1-1h1a8.003 8.003 0 0 1 7.75 6.006A7.985 7.985 0 0 1 19 6h1a1 1 0 0 1 1 1v1a8 8 0 0 1-8 8v4a1 1 0 1 1-2 0v-7a8 8 0 0 1-8-8V4Zm2 1a6 6 0 0 1 6 6 6 6 0 0 1-6-6Zm8 9a6 6 0 0 1 6-6 6 6 0 0 1-6 6Z" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/hashtag_stroke2_corner0_rounded.svg b/assets/icons/hashtag_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..05b2353db
--- /dev/null
+++ b/assets/icons/hashtag_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="M9.124 3.008a1 1 0 0 1 .868 1.116L9.632 7h5.985l.39-3.124a1 1 0 0 1 1.985.248L17.632 7H20a1 1 0 1 1 0 2h-2.617l-.75 6H20a1 1 0 1 1 0 2h-3.617l-.39 3.124a1 1 0 1 1-1.985-.248l.36-2.876H8.382l-.39 3.124a1 1 0 1 1-1.985-.248L6.368 17H4a1 1 0 1 1 0-2h2.617l.75-6H4a1 1 0 1 1 0-2h3.617l.39-3.124a1 1 0 0 1 1.117-.868ZM9.383 9l-.75 6h5.984l.75-6H9.383Z" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/listMagnifyingGlass_stroke2_corner0_rounded.svg b/assets/icons/listMagnifyingGlass_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..1b1857efa
--- /dev/null
+++ b/assets/icons/listMagnifyingGlass_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="M3 4a1 1 0 0 1 1-1h13a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1Zm1 4a1 1 0 0 0 0 2h5a1 1 0 0 0 0-2H4Zm-1 7a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1Zm0 5a1 1 0 0 1 1-1h13a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1Zm9-8a4 4 0 1 1 7.446 2.032l.99.989a1 1 0 1 1-1.415 1.414l-.99-.989A4 4 0 0 1 12 12Zm4-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/listSparkle_stroke2_corner0_rounded.svg b/assets/icons/listSparkle_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..702e1895f
--- /dev/null
+++ b/assets/icons/listSparkle_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 5a1 1 0 0 0 0 2h16a1 1 0 1 0 0-2H4Zm0 12a1 1 0 1 0 0 2h3a1 1 0 1 0 0-2H4Zm-1-5a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1Zm14-3a1 1 0 0 1 .92.606l1.342 3.132 3.132 1.343a1 1 0 0 1 0 1.838l-3.132 1.343-1.343 3.132a1 1 0 0 1-1.838 0l-1.343-3.132-3.132-1.343a1 1 0 0 1 0-1.838l3.132-1.343 1.343-3.132A1 1 0 0 1 17 9Zm0 3.539-.58 1.355a1 1 0 0 1-.526.525L14.539 15l1.355.58a1 1 0 0 1 .525.526L17 17.461l.58-1.355a1 1 0 0 1 .526-.525L19.461 15l-1.355-.58a1 1 0 0 1-.525-.526L17 12.539Z" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/loader_stroke2_corner0_rounded.svg b/assets/icons/loader_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..9dbc01379
--- /dev/null
+++ b/assets/icons/loader_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="M12 5a7 7 0 0 0-5.218 11.666A1 1 0 0 1 5.292 18a9 9 0 1 1 13.416 0 1 1 0 1 1-1.49-1.334A7 7 0 0 0 12 5Z" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/news2_stroke2_corner0_rounded.svg b/assets/icons/news2_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..66e4c373a
--- /dev/null
+++ b/assets/icons/news2_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="M1 5a1 1 0 0 1 1-1h7a3.99 3.99 0 0 1 3 1.354A3.99 3.99 0 0 1 15 4h7a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1h-6.723c-.52 0-1 .125-1.4.372-.421.26-.761.633-.983 1.075a1 1 0 0 1-1.788 0 2.664 2.664 0 0 0-.983-1.075c-.4-.247-.88-.372-1.4-.372H2a1 1 0 0 1-1-1V5Zm10 3a2 2 0 0 0-2-2H3v12h5.723c.776 0 1.564.173 2.277.569V8Zm2 10.569V8a2 2 0 0 1 2-2h6v12h-5.723c-.776 0-1.564.173-2.277.569Z" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/plusLarge_stroke2_corner0_rounded.svg b/assets/icons/plusLarge_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..8d568437b
--- /dev/null
+++ b/assets/icons/plusLarge_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="M12 3a1 1 0 0 1 1 1v7h7a1 1 0 1 1 0 2h-7v7a1 1 0 1 1-2 0v-7H4a1 1 0 1 1 0-2h7V4a1 1 0 0 1 1-1Z" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/trending2_stroke2_corner2_rounded.svg b/assets/icons/trending2_stroke2_corner2_rounded.svg
new file mode 100644
index 000000000..cc806b0eb
--- /dev/null
+++ b/assets/icons/trending2_stroke2_corner2_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="m18.192 5.004 1.864 5.31a1 1 0 0 0 1.887-.662L20.08 4.34c-.665-1.893-3.378-1.741-3.834.207l-3.381 14.449-2.985-9.605C9.3 7.531 6.684 7.506 6.07 9.355l-1.18 3.56-.969-2.312a1 1 0 0 0-1.844.772l.97 2.315c.715 1.71 3.159 1.613 3.741-.144l1.18-3.56 2.985 9.605c.607 1.952 3.392 1.848 3.857-.138l3.381-14.449Z" clip-rule="evenodd"/></svg>
diff --git a/bskyweb/templates/base.html b/bskyweb/templates/base.html
index 228c3d894..4fb3a2095 100644
--- a/bskyweb/templates/base.html
+++ b/bskyweb/templates/base.html
@@ -39,25 +39,6 @@
       scrollbar-gutter: stable both-edges;
     }
 
-    /* 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;
@@ -86,6 +67,28 @@
       }
     }
 
+    ::selection {
+      background-color: var(--backgroundLight);
+    }
+
+    /* Remove autofill styles on Webkit */
+    input:autofill,
+    input:-webkit-autofill,
+    input:-webkit-autofill:hover,
+    input:-webkit-autofill:focus,
+    input:-webkit-autofill:active{
+        -webkit-background-clip: text;
+        -webkit-text-fill-color: var(--text);
+        transition: background-color 5000s ease-in-out 0s;
+        box-shadow: inset 0 0 20px 20px var(--background);
+        background: var(--background);
+        color: var(--text);
+    }
+    /* Force left-align date/time inputs on iOS mobile */
+    input::-webkit-date-and-time-value {
+      text-align: left;
+    }
+
     body {
       display: flex;
       /* Allows you to scroll below the viewport; default value is visible */
diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts
index 203c2f282..bbf7e3243 100644
--- a/src/alf/atoms.ts
+++ b/src/alf/atoms.ts
@@ -104,6 +104,9 @@ export const atoms = {
   flex: {
     display: 'flex',
   },
+  flex_col: {
+    flexDirection: 'column',
+  },
   flex_row: {
     flexDirection: 'row',
   },
@@ -149,45 +152,38 @@ export const atoms = {
   },
   text_2xs: {
     fontSize: tokens.fontSize._2xs,
-    lineHeight: tokens.fontSize._2xs,
   },
   text_xs: {
     fontSize: tokens.fontSize.xs,
-    lineHeight: tokens.fontSize.xs,
   },
   text_sm: {
     fontSize: tokens.fontSize.sm,
-    lineHeight: tokens.fontSize.sm,
   },
   text_md: {
     fontSize: tokens.fontSize.md,
-    lineHeight: tokens.fontSize.md,
   },
   text_lg: {
     fontSize: tokens.fontSize.lg,
-    lineHeight: tokens.fontSize.lg,
   },
   text_xl: {
     fontSize: tokens.fontSize.xl,
-    lineHeight: tokens.fontSize.xl,
   },
   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.15,
+  },
+  leading_snug: {
     lineHeight: 1.25,
   },
   leading_normal: {
diff --git a/src/alf/index.tsx b/src/alf/index.tsx
index 69a879853..06d6ebf01 100644
--- a/src/alf/index.tsx
+++ b/src/alf/index.tsx
@@ -2,6 +2,7 @@ import React from 'react'
 import {Dimensions} from 'react-native'
 import * as themes from '#/alf/themes'
 
+export * from '#/alf/types'
 export * as tokens from '#/alf/tokens'
 export {atoms} from '#/alf/atoms'
 export * from '#/alf/util/platform'
diff --git a/src/alf/tokens.ts b/src/alf/tokens.ts
index 0e370cdc1..f3ae80275 100644
--- a/src/alf/tokens.ts
+++ b/src/alf/tokens.ts
@@ -142,6 +142,14 @@ export const gradients = {
     ],
     hover_value: '#B88BB6',
   },
+  summer: {
+    values: [
+      [0, '#FF6A56'],
+      [0.3, '#FF9156'],
+      [1, '#FFDD87'],
+    ],
+    hover_value: '#FF9156',
+  },
   nordic: {
     values: [
       [0, '#083367'],
diff --git a/src/alf/types.ts b/src/alf/types.ts
index 76ac05d40..dd8d816d2 100644
--- a/src/alf/types.ts
+++ b/src/alf/types.ts
@@ -1,3 +1,5 @@
+import {StyleProp, ViewStyle, TextStyle} from 'react-native'
+
 type LiteralToCommon<T extends PropertyKey> = T extends number
   ? number
   : T extends string
@@ -14,3 +16,11 @@ export type Mutable<T> = {
     ? LiteralToCommon<T[K]>
     : Mutable<T[K]>
 }
+
+export type TextStyleProp = {
+  style?: StyleProp<TextStyle>
+}
+
+export type ViewStyleProp = {
+  style?: StyleProp<ViewStyle>
+}
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
index 7c682ac1a..f88fbcbde 100644
--- a/src/components/Button.tsx
+++ b/src/components/Button.tsx
@@ -9,10 +9,11 @@ import {
   View,
   TextStyle,
   StyleSheet,
+  StyleProp,
 } from 'react-native'
 import LinearGradient from 'react-native-linear-gradient'
 
-import {useTheme, atoms as a, tokens, web, native} from '#/alf'
+import {useTheme, atoms as a, tokens, android, flatten} from '#/alf'
 import {Props as SVGIconProps} from '#/components/icons/common'
 
 export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient'
@@ -27,6 +28,7 @@ export type ButtonColor =
   | 'gradient_nordic'
   | 'gradient_bonfire'
 export type ButtonSize = 'small' | 'large'
+export type ButtonShape = 'round' | 'square' | 'default'
 export type VariantProps = {
   /**
    * The style variation of the button
@@ -40,6 +42,10 @@ export type VariantProps = {
    * The size of the button
    */
   size?: ButtonSize
+  /**
+   * The shape of the button
+   */
+  shape?: ButtonShape
 }
 
 export type ButtonProps = React.PropsWithChildren<
@@ -47,6 +53,7 @@ export type ButtonProps = React.PropsWithChildren<
     AccessibilityProps &
     VariantProps & {
       label: string
+      style?: StyleProp<ViewStyle>
     }
 >
 export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean}
@@ -74,8 +81,10 @@ export function Button({
   variant,
   color,
   size,
+  shape = 'default',
   label,
   disabled = false,
+  style,
   ...rest
 }: ButtonProps) {
   const t = useTheme()
@@ -175,18 +184,18 @@ export function Button({
         if (!disabled) {
           baseStyles.push({
             backgroundColor: light
-              ? tokens.color.gray_100
+              ? tokens.color.gray_50
               : tokens.color.gray_900,
           })
           hoverStyles.push({
             backgroundColor: light
-              ? tokens.color.gray_200
+              ? tokens.color.gray_100
               : tokens.color.gray_950,
           })
         } else {
           baseStyles.push({
             backgroundColor: light
-              ? tokens.color.gray_300
+              ? tokens.color.gray_200
               : tokens.color.gray_950,
           })
         }
@@ -197,7 +206,7 @@ export function Button({
 
         if (!disabled) {
           baseStyles.push(a.border, {
-            borderColor: light ? tokens.color.gray_500 : tokens.color.gray_500,
+            borderColor: light ? tokens.color.gray_300 : tokens.color.gray_700,
           })
           hoverStyles.push(a.border, t.atoms.bg_contrast_50)
         } else {
@@ -262,10 +271,28 @@ export function Button({
       }
     }
 
-    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)
+    if (shape === 'default') {
+      if (size === 'large') {
+        baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_md)
+      } else if (size === 'small') {
+        baseStyles.push({paddingVertical: 9}, a.px_lg, a.rounded_sm, a.gap_sm)
+      }
+    } else if (shape === 'round' || shape === 'square') {
+      if (size === 'large') {
+        if (shape === 'round') {
+          baseStyles.push({height: 54, width: 54})
+        } else {
+          baseStyles.push({height: 50, width: 50})
+        }
+      } else if (size === 'small') {
+        baseStyles.push({height: 40, width: 40})
+      }
+
+      if (shape === 'round') {
+        baseStyles.push(a.rounded_full)
+      } else if (shape === 'square') {
+        baseStyles.push(a.rounded_sm)
+      }
     }
 
     return {
@@ -278,7 +305,7 @@ export function Button({
         } as ViewStyle,
       ],
     }
-  }, [t, variant, color, size, disabled])
+  }, [t, variant, color, size, shape, disabled])
 
   const {gradientColors, gradientHoverColors, gradientLocations} =
     React.useMemo(() => {
@@ -334,8 +361,10 @@ export function Button({
         disabled: disabled || false,
       }}
       style={[
+        flatten(style),
         a.flex_row,
         a.align_center,
+        a.justify_center,
         a.overflow_hidden,
         a.justify_center,
         ...baseStyles,
@@ -462,17 +491,9 @@ export function useSharedButtonTextStyles() {
     }
 
     if (size === 'large') {
-      baseStyles.push(
-        a.text_md,
-        web({paddingBottom: 1}),
-        native({marginTop: 2}),
-      )
+      baseStyles.push(a.text_md, android({paddingBottom: 1}))
     } else {
-      baseStyles.push(
-        a.text_md,
-        web({paddingBottom: 1}),
-        native({marginTop: 2}),
-      )
+      baseStyles.push(a.text_sm, android({paddingBottom: 1}))
     }
 
     return StyleSheet.flatten(baseStyles)
@@ -491,14 +512,24 @@ export function ButtonText({children, style, ...rest}: ButtonTextProps) {
 
 export function ButtonIcon({
   icon: Comp,
+  position,
 }: {
   icon: React.ComponentType<SVGIconProps>
+  position?: 'left' | 'right'
 }) {
-  const {size} = useButtonContext()
+  const {size, disabled} = useButtonContext()
   const textStyles = useSharedButtonTextStyles()
 
   return (
-    <View style={[a.z_20]}>
+    <View
+      style={[
+        a.z_20,
+        {
+          opacity: disabled ? 0.7 : 1,
+          marginLeft: position === 'left' ? -2 : 0,
+          marginRight: position === 'right' ? -2 : 0,
+        },
+      ]}>
       <Comp
         size={size === 'large' ? 'md' : 'sm'}
         style={[{color: textStyles.color, pointerEvents: 'none'}]}
diff --git a/src/components/Divider.tsx b/src/components/Divider.tsx
new file mode 100644
index 000000000..9b8f79fd0
--- /dev/null
+++ b/src/components/Divider.tsx
@@ -0,0 +1,10 @@
+import React from 'react'
+import {View} from 'react-native'
+import {atoms as a, useTheme} from '#/alf'
+import {ViewStyleProp} from '#/alf'
+
+export function Divider({style}: ViewStyleProp) {
+  const t = useTheme()
+
+  return <View style={[a.w_full, a.border_t, t.atoms.border, style]} />
+}
diff --git a/src/components/Link.tsx b/src/components/Link.tsx
index 8f686f3c4..63b0c73f1 100644
--- a/src/components/Link.tsx
+++ b/src/components/Link.tsx
@@ -1,10 +1,8 @@
 import React from 'react'
 import {
-  Text,
-  TextStyle,
-  StyleProp,
   GestureResponderEvent,
   Linking,
+  TouchableWithoutFeedback,
 } from 'react-native'
 import {
   useLinkProps,
@@ -13,9 +11,10 @@ import {
 } from '@react-navigation/native'
 import {sanitizeUrl} from '@braintree/sanitize-url'
 
+import {useInteractionState} from '#/components/hooks/useInteractionState'
 import {isWeb} from '#/platform/detection'
-import {useTheme, web, flatten} from '#/alf'
-import {Button, ButtonProps, useButtonContext} from '#/components/Button'
+import {useTheme, web, flatten, TextStyleProp} from '#/alf'
+import {Button, ButtonProps} from '#/components/Button'
 import {AllNavigatorParams, NavigationProp} from '#/lib/routes/types'
 import {
   convertBskyAppUrlIfNeeded,
@@ -24,43 +23,39 @@ import {
 } from '#/lib/strings/url-helpers'
 import {useModalControls} from '#/state/modals'
 import {router} from '#/routes'
+import {Text} from '#/components/Typography'
 
-export type LinkProps = Omit<
-  ButtonProps,
-  'style' | 'onPress' | 'disabled' | 'label'
+/**
+ * Only available within a `Link`, since that inherits from `Button`.
+ * `InlineLink` provides no context.
+ */
+export {useButtonContext as useLinkContext} from '#/components/Button'
+
+type BaseLinkProps = Pick<
+  Parameters<typeof useLinkProps<AllNavigatorParams>>[0],
+  'to'
 > & {
   /**
-   * `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.
+   * If true, will warn the user if the link text does not match the href.
+   *
+   * Note: atm this only works for `InlineLink`s with a string child.
    */
   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,
+export function useLink({
   to,
+  displayText,
   action = 'push',
   warnOnMismatchingTextChild,
-  style,
-  ...rest
-}: LinkProps) {
+}: BaseLinkProps & {
+  displayText: string
+}) {
   const navigation = useNavigation<NavigationProp>()
   const {href} = useLinkProps<AllNavigatorParams>({
     to:
@@ -68,14 +63,14 @@ export function Link({
   })
   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 &&
+          displayText &&
           isExternal &&
-          linkRequiresWarning(href, stringChildren),
+          linkRequiresWarning(href, displayText),
       )
 
       if (requiresWarning) {
@@ -83,7 +78,7 @@ export function Link({
 
         openModal({
           name: 'link-warning',
-          text: stringChildren,
+          text: displayText,
           href: href,
         })
       } else {
@@ -134,12 +129,42 @@ export function Link({
       warnOnMismatchingTextChild,
       navigation,
       action,
-      children,
+      displayText,
       closeModal,
       openModal,
     ],
   )
 
+  return {
+    isExternal,
+    href,
+    onPress,
+  }
+}
+
+export type LinkProps = Omit<BaseLinkProps, 'warnOnMismatchingTextChild'> &
+  Omit<ButtonProps, 'style' | 'onPress' | 'disabled' | 'label'> & {
+    /**
+     * Label for a11y. Defaults to the href.
+     */
+    label?: string
+  }
+
+/**
+ * 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', ...rest}: LinkProps) {
+  const {href, isExternal, onPress} = useLink({
+    to,
+    displayText: typeof children === 'string' ? children : '',
+    action,
+  })
+
   return (
     <Button
       label={href}
@@ -158,34 +183,81 @@ export function Link({
           noUnderline: '1',
         },
       })}>
-      {typeof children === 'string' ? (
-        <LinkText style={style}>{children}</LinkText>
-      ) : (
-        children
-      )}
+      {children}
     </Button>
   )
 }
 
-function LinkText({
+export type InlineLinkProps = React.PropsWithChildren<
+  BaseLinkProps &
+    TextStyleProp & {
+      /**
+       * Label for a11y. Defaults to the href.
+       */
+      label?: string
+    }
+>
+
+export function InlineLink({
   children,
+  to,
+  action = 'push',
+  warnOnMismatchingTextChild,
   style,
-}: React.PropsWithChildren<{
-  style?: StyleProp<TextStyle>
-}>) {
+  ...rest
+}: InlineLinkProps) {
   const t = useTheme()
-  const {hovered} = useButtonContext()
+  const stringChildren = typeof children === 'string'
+  const {href, isExternal, onPress} = useLink({
+    to,
+    displayText: stringChildren ? children : '',
+    action,
+    warnOnMismatchingTextChild,
+  })
+  const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
+  const {
+    state: pressed,
+    onIn: onPressIn,
+    onOut: onPressOut,
+  } = useInteractionState()
+
   return (
-    <Text
-      style={[
-        {color: t.palette.primary_500},
-        hovered && {
-          textDecorationLine: 'underline',
-          textDecorationColor: t.palette.primary_500,
-        },
-        flatten(style),
-      ]}>
-      {children as string}
-    </Text>
+    <TouchableWithoutFeedback
+      accessibilityRole="button"
+      onPress={onPress}
+      onPressIn={onPressIn}
+      onPressOut={onPressOut}
+      onFocus={onFocus}
+      onBlur={onBlur}>
+      <Text
+        label={href}
+        {...rest}
+        style={[
+          {color: t.palette.primary_500},
+          (focused || pressed) && {
+            outline: 0,
+            textDecorationLine: 'underline',
+            textDecorationColor: t.palette.primary_500,
+          },
+          flatten(style),
+        ]}
+        role="link"
+        accessibilityRole="link"
+        href={href}
+        {...web({
+          hrefAttrs: {
+            target: isExternal ? 'blank' : undefined,
+            rel: isExternal ? 'noopener noreferrer' : undefined,
+          },
+          dataSet: stringChildren
+            ? {}
+            : {
+                // default to no underline, apply this ourselves
+                noUnderline: '1',
+              },
+        })}>
+        {children}
+      </Text>
+    </TouchableWithoutFeedback>
   )
 }
diff --git a/src/components/Portal.tsx b/src/components/Portal.tsx
index 1813d9e05..d696f986b 100644
--- a/src/components/Portal.tsx
+++ b/src/components/Portal.tsx
@@ -12,45 +12,54 @@ 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 createPortalGroup() {
+  const Context = React.createContext<ContextType>({
+    outlet: null,
+    append: () => {},
+    remove: () => {},
+  })
 
-export function Outlet() {
-  const ctx = React.useContext(Context)
-  return ctx.outlet
-}
+  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)}</>)
+    }, [])
 
-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
+    return (
+      <Context.Provider value={{outlet, append, remove}}>
+        {props.children}
+      </Context.Provider>
+    )
+  }
+
+  function Outlet() {
+    const ctx = React.useContext(Context)
+    return ctx.outlet
+  }
+
+  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
+  }
+
+  return {Provider, Outlet, Portal}
 }
+
+const DefaultPortal = createPortalGroup()
+export const Provider = DefaultPortal.Provider
+export const Outlet = DefaultPortal.Outlet
+export const Portal = DefaultPortal.Portal
diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx
new file mode 100644
index 000000000..068ee99e0
--- /dev/null
+++ b/src/components/RichText.tsx
@@ -0,0 +1,131 @@
+import React from 'react'
+import {RichText as RichTextAPI, AppBskyRichtextFacet} from '@atproto/api'
+
+import {atoms as a, TextStyleProp} from '#/alf'
+import {InlineLink} from '#/components/Link'
+import {Text} from '#/components/Typography'
+import {toShortUrl} from 'lib/strings/url-helpers'
+import {getAgent} from '#/state/session'
+
+const WORD_WRAP = {wordWrap: 1}
+
+export function RichText({
+  testID,
+  value,
+  style,
+  numberOfLines,
+  disableLinks,
+  resolveFacets = false,
+}: TextStyleProp & {
+  value: RichTextAPI | string
+  testID?: string
+  numberOfLines?: number
+  disableLinks?: boolean
+  resolveFacets?: boolean
+}) {
+  const detected = React.useRef(false)
+  const [richText, setRichText] = React.useState<RichTextAPI>(() =>
+    value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
+  )
+  const styles = [a.leading_normal, style]
+
+  React.useEffect(() => {
+    if (!resolveFacets) return
+
+    async function detectFacets() {
+      const rt = new RichTextAPI({text: richText.text})
+      await rt.detectFacets(getAgent())
+      setRichText(rt)
+    }
+
+    if (!detected.current) {
+      detected.current = true
+      detectFacets()
+    }
+  }, [richText, setRichText, resolveFacets])
+
+  const {text, facets} = richText
+
+  if (!facets?.length) {
+    if (text.length <= 5 && /^\p{Extended_Pictographic}+$/u.test(text)) {
+      return (
+        <Text
+          testID={testID}
+          style={[
+            {
+              fontSize: 26,
+              lineHeight: 30,
+            },
+          ]}
+          // @ts-ignore web only -prf
+          dataSet={WORD_WRAP}>
+          {text}
+        </Text>
+      )
+    }
+    return (
+      <Text
+        testID={testID}
+        style={styles}
+        numberOfLines={numberOfLines}
+        // @ts-ignore web only -prf
+        dataSet={WORD_WRAP}>
+        {text}
+      </Text>
+    )
+  }
+
+  const els = []
+  let key = 0
+  // N.B. must access segments via `richText.segments`, not via destructuring
+  for (const segment of richText.segments()) {
+    const link = segment.link
+    const mention = segment.mention
+    if (
+      mention &&
+      AppBskyRichtextFacet.validateMention(mention).success &&
+      !disableLinks
+    ) {
+      els.push(
+        <InlineLink
+          key={key}
+          to={`/profile/${mention.did}`}
+          style={[...styles, {pointerEvents: 'auto'}]}
+          // @ts-ignore TODO
+          dataSet={WORD_WRAP}>
+          {segment.text}
+        </InlineLink>,
+      )
+    } else if (link && AppBskyRichtextFacet.validateLink(link).success) {
+      if (disableLinks) {
+        els.push(toShortUrl(segment.text))
+      } else {
+        els.push(
+          <InlineLink
+            key={key}
+            to={link.uri}
+            style={[...styles, {pointerEvents: 'auto'}]}
+            // @ts-ignore TODO
+            dataSet={WORD_WRAP}
+            warnOnMismatchingLabel>
+            {toShortUrl(segment.text)}
+          </InlineLink>,
+        )
+      }
+    } else {
+      els.push(segment.text)
+    }
+    key++
+  }
+
+  return (
+    <Text
+      testID={testID}
+      style={styles}
+      numberOfLines={numberOfLines}
+      // @ts-ignore web only -prf
+      dataSet={WORD_WRAP}>
+      {els}
+    </Text>
+  )
+}
diff --git a/src/components/Typography.tsx b/src/components/Typography.tsx
index 66cf0720d..64aa6d1a4 100644
--- a/src/components/Typography.tsx
+++ b/src/components/Typography.tsx
@@ -1,11 +1,50 @@
 import React from 'react'
-import {Text as RNText, TextProps} from 'react-native'
+import {Text as RNText, TextStyle, TextProps} from 'react-native'
 
 import {useTheme, atoms, web, flatten} from '#/alf'
 
+/**
+ * Util to calculate lineHeight from a text size atom and a leading atom
+ *
+ * Example:
+ *   `leading(atoms.text_md, atoms.leading_normal)` // => 24
+ */
+export function leading<
+  Size extends {fontSize?: number},
+  Leading extends {lineHeight?: number},
+>(textSize: Size, leading: Leading) {
+  const size = textSize?.fontSize || atoms.text_md.fontSize
+  const lineHeight = leading?.lineHeight || atoms.leading_normal.lineHeight
+  return size * lineHeight
+}
+
+/**
+ * Ensures that `lineHeight` defaults to a relative value of `1`, or applies
+ * other relative leading atoms.
+ *
+ * If the `lineHeight` value is > 2, we assume it's an absolute value and
+ * returns it as-is.
+ */
+function normalizeTextStyles(styles: TextStyle[]) {
+  const s = flatten(styles)
+  // should always be defined on these components
+  const fontSize = s.fontSize || atoms.text_md.fontSize
+
+  if (s?.lineHeight) {
+    if (s.lineHeight <= 2) {
+      s.lineHeight = fontSize * s.lineHeight
+    }
+  } else {
+    s.lineHeight = fontSize
+  }
+
+  return s
+}
+
 export function Text({style, ...rest}: TextProps) {
   const t = useTheme()
-  return <RNText style={[atoms.text_sm, t.atoms.text, style]} {...rest} />
+  const s = normalizeTextStyles([atoms.text_sm, t.atoms.text, flatten(style)])
+  return <RNText style={s} {...rest} />
 }
 
 export function H1({style, ...rest}: TextProps) {
@@ -19,7 +58,12 @@ export function H1({style, ...rest}: TextProps) {
     <RNText
       {...attr}
       {...rest}
-      style={[atoms.text_5xl, atoms.font_bold, t.atoms.text, flatten(style)]}
+      style={normalizeTextStyles([
+        atoms.text_5xl,
+        atoms.font_bold,
+        t.atoms.text,
+        flatten(style),
+      ])}
     />
   )
 }
@@ -35,7 +79,12 @@ export function H2({style, ...rest}: TextProps) {
     <RNText
       {...attr}
       {...rest}
-      style={[atoms.text_4xl, atoms.font_bold, t.atoms.text, flatten(style)]}
+      style={normalizeTextStyles([
+        atoms.text_4xl,
+        atoms.font_bold,
+        t.atoms.text,
+        flatten(style),
+      ])}
     />
   )
 }
@@ -51,7 +100,12 @@ export function H3({style, ...rest}: TextProps) {
     <RNText
       {...attr}
       {...rest}
-      style={[atoms.text_3xl, atoms.font_bold, t.atoms.text, flatten(style)]}
+      style={normalizeTextStyles([
+        atoms.text_3xl,
+        atoms.font_bold,
+        t.atoms.text,
+        flatten(style),
+      ])}
     />
   )
 }
@@ -67,7 +121,12 @@ export function H4({style, ...rest}: TextProps) {
     <RNText
       {...attr}
       {...rest}
-      style={[atoms.text_2xl, atoms.font_bold, t.atoms.text, flatten(style)]}
+      style={normalizeTextStyles([
+        atoms.text_2xl,
+        atoms.font_bold,
+        t.atoms.text,
+        flatten(style),
+      ])}
     />
   )
 }
@@ -83,7 +142,12 @@ export function H5({style, ...rest}: TextProps) {
     <RNText
       {...attr}
       {...rest}
-      style={[atoms.text_xl, atoms.font_bold, t.atoms.text, flatten(style)]}
+      style={normalizeTextStyles([
+        atoms.text_xl,
+        atoms.font_bold,
+        t.atoms.text,
+        flatten(style),
+      ])}
     />
   )
 }
@@ -99,7 +163,12 @@ export function H6({style, ...rest}: TextProps) {
     <RNText
       {...attr}
       {...rest}
-      style={[atoms.text_lg, atoms.font_bold, t.atoms.text, flatten(style)]}
+      style={normalizeTextStyles([
+        atoms.text_lg,
+        atoms.font_bold,
+        t.atoms.text,
+        flatten(style),
+      ])}
     />
   )
 }
@@ -110,15 +179,16 @@ export function P({style, ...rest}: TextProps) {
     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}]}
+      style={normalizeTextStyles([
+        atoms.text_md,
+        atoms.leading_normal,
+        t.atoms.text,
+        flatten(style),
+      ])}
     />
   )
 }
diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx
index 1ee58303a..67515049c 100644
--- a/src/components/forms/TextField.tsx
+++ b/src/components/forms/TextField.tsx
@@ -208,7 +208,7 @@ export function createInput(Component: typeof TextInput) {
               paddingBottom: 2,
             }),
             {
-              lineHeight: a.text_md.lineHeight * 1.1875,
+              lineHeight: a.text_md.fontSize * 1.1875,
               textAlignVertical: rest.multiline ? 'top' : undefined,
               minHeight: rest.multiline ? 60 : undefined,
             },
diff --git a/src/components/forms/Toggle.tsx b/src/components/forms/Toggle.tsx
index ad82bdff5..d3c034246 100644
--- a/src/components/forms/Toggle.tsx
+++ b/src/components/forms/Toggle.tsx
@@ -2,7 +2,7 @@ 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 {useTheme, atoms as a, web, native, flatten, ViewStyleProp} from '#/alf'
 import {Text} from '#/components/Typography'
 import {useInteractionState} from '#/components/hooks/useInteractionState'
 
@@ -49,7 +49,7 @@ export type GroupProps = React.PropsWithChildren<{
   label: string
 }>
 
-export type ItemProps = {
+export type ItemProps = ViewStyleProp & {
   type?: 'radio' | 'checkbox'
   name: string
   label: string
@@ -57,7 +57,6 @@ export type ItemProps = {
   disabled?: boolean
   onChange?: (selected: boolean) => void
   isInvalid?: boolean
-  style?: (state: ItemState) => ViewStyle
   children: ((props: ItemState) => React.ReactNode) | React.ReactNode
 }
 
@@ -125,6 +124,7 @@ export function Group({
   return (
     <GroupContext.Provider value={context}>
       <View
+        style={[a.w_full]}
         role={groupRole}
         {...(groupRole === 'radiogroup'
           ? {
@@ -224,7 +224,7 @@ export function Item({
           a.align_center,
           a.gap_sm,
           focused ? web({outline: 'none'}) : {},
-          style?.(state),
+          flatten(style),
         ]}>
         {typeof children === 'function' ? children(state) : children}
       </Pressable>
diff --git a/src/components/forms/ToggleButton.tsx b/src/components/forms/ToggleButton.tsx
index 615fedae8..5cd51d794 100644
--- a/src/components/forms/ToggleButton.tsx
+++ b/src/components/forms/ToggleButton.tsx
@@ -20,6 +20,7 @@ export function Group({children, multiple, ...props}: GroupProps) {
     <Toggle.Group type={multiple ? 'checkbox' : 'radio'} {...props}>
       <View
         style={[
+          a.w_full,
           a.flex_row,
           a.border,
           a.rounded_sm,
@@ -34,7 +35,7 @@ export function Group({children, multiple, ...props}: GroupProps) {
 
 export function Button({children, ...props}: ItemProps) {
   return (
-    <Toggle.Item {...props}>
+    <Toggle.Item {...props} style={[a.flex_grow]}>
       <ButtonInner>{children}</ButtonInner>
     </Toggle.Item>
   )
@@ -95,11 +96,12 @@ function ButtonInner({children}: React.PropsWithChildren<{}>) {
           borderLeftWidth: 1,
           marginLeft: -1,
         },
-        a.px_lg,
+        a.flex_grow,
         a.py_md,
         native({
-          paddingTop: 14,
+          paddingBottom: 10,
         }),
+        a.px_sm,
         t.atoms.bg,
         t.atoms.border,
         baseStyles,
diff --git a/src/components/icons/ArrowRotateCounterClockwise.tsx b/src/components/icons/ArrowRotateCounterClockwise.tsx
new file mode 100644
index 000000000..35cd23a97
--- /dev/null
+++ b/src/components/icons/ArrowRotateCounterClockwise.tsx
@@ -0,0 +1,6 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded =
+  createSinglePathSVG({
+    path: 'M5 3a1 1 0 0 1 1 1v1.423c.498-.46 1.02-.869 1.58-1.213C8.863 3.423 10.302 3 12.028 3a9 9 0 1 1-8.487 12 1 1 0 0 1 1.885-.667A7 7 0 1 0 12.028 5c-1.37 0-2.444.327-3.402.915-.474.29-.93.652-1.383 1.085H9a1 1 0 0 1 0 2H5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Z',
+  })
diff --git a/src/components/icons/At.tsx b/src/components/icons/At.tsx
new file mode 100644
index 000000000..248725054
--- /dev/null
+++ b/src/components/icons/At.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const At_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M12 4a8 8 0 1 0 4.21 14.804 1 1 0 0 1 1.054 1.7A9.958 9.958 0 0 1 12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10c0 1.104-.27 2.31-.949 3.243-.716.984-1.849 1.6-3.331 1.465a4.207 4.207 0 0 1-2.93-1.585c-.94 1.21-2.388 1.94-3.985 1.715-2.53-.356-4.04-2.91-3.682-5.458.358-2.547 2.514-4.586 5.044-4.23.905.127 1.68.536 2.286 1.126a1 1 0 0 1 1.964.368l-.515 3.545v.002a2.222 2.222 0 0 0 1.999 2.526c.75.068 1.212-.21 1.533-.65.358-.493.566-1.245.566-2.067a8 8 0 0 0-8-8Zm-.112 5.13c-1.195-.168-2.544.819-2.784 2.529-.24 1.71.784 3.03 1.98 3.198 1.195.168 2.543-.819 2.784-2.529.24-1.71-.784-3.03-1.98-3.198Z',
+})
diff --git a/src/components/icons/Check.tsx b/src/components/icons/Check.tsx
new file mode 100644
index 000000000..24316c784
--- /dev/null
+++ b/src/components/icons/Check.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Check_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M21.59 3.193a1 1 0 0 1 .217 1.397l-11.706 16a1 1 0 0 1-1.429.193l-6.294-5a1 1 0 1 1 1.244-1.566l5.48 4.353 11.09-15.16a1 1 0 0 1 1.398-.217Z',
+})
diff --git a/src/components/icons/Chevron.tsx b/src/components/icons/Chevron.tsx
new file mode 100644
index 000000000..b1a9deea0
--- /dev/null
+++ b/src/components/icons/Chevron.tsx
@@ -0,0 +1,9 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const ChevronLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M15.707 3.293a1 1 0 0 1 0 1.414L8.414 12l7.293 7.293a1 1 0 0 1-1.414 1.414l-8-8a1 1 0 0 1 0-1.414l8-8a1 1 0 0 1 1.414 0Z',
+})
+
+export const ChevronRight_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M8.293 3.293a1 1 0 0 1 1.414 0l8 8a1 1 0 0 1 0 1.414l-8 8a1 1 0 0 1-1.414-1.414L15.586 12 8.293 4.707a1 1 0 0 1 0-1.414Z',
+})
diff --git a/src/components/icons/CircleInfo.tsx b/src/components/icons/CircleInfo.tsx
new file mode 100644
index 000000000..cc3813bf3
--- /dev/null
+++ b/src/components/icons/CircleInfo.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const CircleInfo_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm8-1a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-4a1 1 0 0 1-1-1Zm1-3a1 1 0 1 0 2 0 1 1 0 0 0-2 0Z',
+})
diff --git a/src/components/icons/Emoji.tsx b/src/components/icons/Emoji.tsx
new file mode 100644
index 000000000..568cd71e6
--- /dev/null
+++ b/src/components/icons/Emoji.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const EmojiSad_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M6.343 6.343a8 8 0 1 1 11.314 11.314A8 8 0 0 1 6.343 6.343ZM19.071 4.93c-3.905-3.905-10.237-3.905-14.142 0-3.905 3.905-3.905 10.237 0 14.142 3.905 3.905 10.237 3.905 14.142 0 3.905-3.905 3.905-10.237 0-14.142Zm-3.537 9.535a5 5 0 0 0-7.07 0 1 1 0 1 0 1.413 1.415 3 3 0 0 1 4.243 0 1 1 0 0 0 1.414-1.415ZM16 9.5c0 .828-.56 1.5-1.25 1.5s-1.25-.672-1.25-1.5.56-1.5 1.25-1.5S16 8.672 16 9.5ZM9.25 11c.69 0 1.25-.672 1.25-1.5S9.94 8 9.25 8 8 8.672 8 9.5 8.56 11 9.25 11Z',
+})
diff --git a/src/components/icons/EyeSlash.tsx b/src/components/icons/EyeSlash.tsx
new file mode 100644
index 000000000..a936a1c71
--- /dev/null
+++ b/src/components/icons/EyeSlash.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const EyeSlash_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M2.293 2.293a1 1 0 0 1 1.414 0L7.335 5.92l.03.03 3.22 3.222 4.243 4.242 3.22 3.22.03.03 3.63 3.629a1 1 0 0 1-1.415 1.414l-3.09-3.09c-2.65 1.478-5.625 1.778-8.421.869-3.039-.987-5.779-3.37-7.67-7.027a1 1 0 0 1 0-.918c1.086-2.1 2.452-3.78 3.996-5.019L2.293 3.707a1 1 0 0 1 0-1.414Zm4.24 5.654 2.021 2.021a4 4 0 0 0 5.478 5.478l1.688 1.688c-2.042.982-4.246 1.124-6.32.45-2.34-.76-4.594-2.586-6.265-5.584.97-1.739 2.135-3.083 3.398-4.053Zm3.535 3.535 2.45 2.45a2 2 0 0 1-2.45-2.45Zm.81-5.405c3.573-.49 7.45 1.369 9.987 5.923a14.797 14.797 0 0 1-1.347 2.02 1 1 0 1 0 1.564 1.247 17.078 17.078 0 0 0 1.806-2.808 1 1 0 0 0 0-.918c-2.833-5.479-7.584-8.088-12.281-7.446a1 1 0 0 0 .271 1.982Z',
+})
diff --git a/src/components/icons/FilterTimeline.tsx b/src/components/icons/FilterTimeline.tsx
new file mode 100644
index 000000000..ea11a429c
--- /dev/null
+++ b/src/components/icons/FilterTimeline.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const FilterTimeline_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M7.002 5a1 1 0 0 0-2 0v11.587l-1.295-1.294a1 1 0 0 0-1.414 1.414l3.002 3a1 1 0 0 0 1.414 0l2.998-3a1 1 0 0 0-1.414-1.414l-1.291 1.292V5ZM16 16a1 1 0 1 0 0 2h4a1 1 0 1 0 0-2h-4Zm-3-4a1 1 0 0 1 1-1h6a1 1 0 1 1 0 2h-6a1 1 0 0 1-1-1Zm-1-6a1 1 0 1 0 0 2h8a1 1 0 1 0 0-2h-8Z',
+})
diff --git a/src/components/icons/Growth.tsx b/src/components/icons/Growth.tsx
new file mode 100644
index 000000000..ab5684a57
--- /dev/null
+++ b/src/components/icons/Growth.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Growth_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M3 4a1 1 0 0 1 1-1h1a8.003 8.003 0 0 1 7.75 6.006A7.985 7.985 0 0 1 19 6h1a1 1 0 0 1 1 1v1a8 8 0 0 1-8 8v4a1 1 0 1 1-2 0v-7a8 8 0 0 1-8-8V4Zm2 1a6 6 0 0 1 6 6 6 6 0 0 1-6-6Zm8 9a6 6 0 0 1 6-6 6 6 0 0 1-6 6Z',
+})
diff --git a/src/components/icons/Hashtag.tsx b/src/components/icons/Hashtag.tsx
new file mode 100644
index 000000000..668ed9256
--- /dev/null
+++ b/src/components/icons/Hashtag.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Hashtag_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M9.124 3.008a1 1 0 0 1 .868 1.116L9.632 7h5.985l.39-3.124a1 1 0 0 1 1.985.248L17.632 7H20a1 1 0 1 1 0 2h-2.617l-.75 6H20a1 1 0 1 1 0 2h-3.617l-.39 3.124a1 1 0 1 1-1.985-.248l.36-2.876H8.382l-.39 3.124a1 1 0 1 1-1.985-.248L6.368 17H4a1 1 0 1 1 0-2h2.617l.75-6H4a1 1 0 1 1 0-2h3.617l.39-3.124a1 1 0 0 1 1.117-.868ZM9.383 9l-.75 6h5.984l.75-6H9.383Z',
+})
diff --git a/src/components/icons/ListMagnifyingGlass.tsx b/src/components/icons/ListMagnifyingGlass.tsx
new file mode 100644
index 000000000..a897fe853
--- /dev/null
+++ b/src/components/icons/ListMagnifyingGlass.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const ListMagnifyingGlass_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M3 4a1 1 0 0 1 1-1h13a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1Zm1 4a1 1 0 0 0 0 2h5a1 1 0 0 0 0-2H4Zm-1 7a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1Zm0 5a1 1 0 0 1 1-1h13a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1Zm9-8a4 4 0 1 1 7.446 2.032l.99.989a1 1 0 1 1-1.415 1.414l-.99-.989A4 4 0 0 1 12 12Zm4-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z',
+})
diff --git a/src/components/icons/ListSparkle.tsx b/src/components/icons/ListSparkle.tsx
new file mode 100644
index 000000000..4d472465d
--- /dev/null
+++ b/src/components/icons/ListSparkle.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const ListSparkle_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M4 5a1 1 0 0 0 0 2h16a1 1 0 1 0 0-2H4Zm0 12a1 1 0 1 0 0 2h3a1 1 0 1 0 0-2H4Zm-1-5a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1Zm14-3a1 1 0 0 1 .92.606l1.342 3.132 3.132 1.343a1 1 0 0 1 0 1.838l-3.132 1.343-1.343 3.132a1 1 0 0 1-1.838 0l-1.343-3.132-3.132-1.343a1 1 0 0 1 0-1.838l3.132-1.343 1.343-3.132A1 1 0 0 1 17 9Zm0 3.539-.58 1.355a1 1 0 0 1-.526.525L14.539 15l1.355.58a1 1 0 0 1 .525.526L17 17.461l.58-1.355a1 1 0 0 1 .526-.525L19.461 15l-1.355-.58a1 1 0 0 1-.525-.526L17 12.539Z',
+})
diff --git a/src/components/icons/News2.tsx b/src/components/icons/News2.tsx
new file mode 100644
index 000000000..f2124e7b8
--- /dev/null
+++ b/src/components/icons/News2.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const News2_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M1 5a1 1 0 0 1 1-1h7a3.99 3.99 0 0 1 3 1.354A3.99 3.99 0 0 1 15 4h7a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1h-6.723c-.52 0-1 .125-1.4.372-.421.26-.761.633-.983 1.075a1 1 0 0 1-1.788 0 2.664 2.664 0 0 0-.983-1.075c-.4-.247-.88-.372-1.4-.372H2a1 1 0 0 1-1-1V5Zm10 3a2 2 0 0 0-2-2H3v12h5.723c.776 0 1.564.173 2.277.569V8Zm2 10.569V8a2 2 0 0 1 2-2h6v12h-5.723c-.776 0-1.564.173-2.277.569Z',
+})
diff --git a/src/components/icons/Plus.tsx b/src/components/icons/Plus.tsx
new file mode 100644
index 000000000..d0698f7f4
--- /dev/null
+++ b/src/components/icons/Plus.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const PlusLarge_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M12 3a1 1 0 0 1 1 1v7h7a1 1 0 1 1 0 2h-7v7a1 1 0 1 1-2 0v-7H4a1 1 0 1 1 0-2h7V4a1 1 0 0 1 1-1Z',
+})
diff --git a/src/components/icons/Trending2.tsx b/src/components/icons/Trending2.tsx
new file mode 100644
index 000000000..5fba4167b
--- /dev/null
+++ b/src/components/icons/Trending2.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Trending2_Stroke2_Corner2_Rounded = createSinglePathSVG({
+  path: 'm18.192 5.004 1.864 5.31a1 1 0 0 0 1.887-.662L20.08 4.34c-.665-1.893-3.378-1.741-3.834.207l-3.381 14.449-2.985-9.605C9.3 7.531 6.684 7.506 6.07 9.355l-1.18 3.56-.969-2.312a1 1 0 0 0-1.844.772l.97 2.315c.715 1.71 3.159 1.613 3.741-.144l1.18-3.56 2.985 9.605c.607 1.952 3.392 1.848 3.857-.138l3.381-14.449Z',
+})
diff --git a/src/lib/analytics/types.ts b/src/lib/analytics/types.ts
index c84f7979a..54e143fa3 100644
--- a/src/lib/analytics/types.ts
+++ b/src/lib/analytics/types.ts
@@ -131,6 +131,38 @@ interface TrackPropertiesMap {
   'Onboarding:Reset': {}
   'Onboarding:SuggestedFollowFollowed': {}
   'Onboarding:CustomFeedAdded': {}
+  // Onboarding v2
+  'OnboardingV2:Begin': {}
+  'OnboardingV2:StepInterests:Start': {}
+  'OnboardingV2:StepInterests:End': {
+    selectedInterests: string[]
+    selectedInterestsLength: number
+  }
+  'OnboardingV2:StepInterests:Error': {}
+  'OnboardingV2:StepSuggestedAccounts:Start': {}
+  'OnboardingV2:StepSuggestedAccounts:End': {
+    selectedAccountsLength: number
+  }
+  'OnboardingV2:StepFollowingFeed:Start': {}
+  'OnboardingV2:StepFollowingFeed:End': {}
+  'OnboardingV2:StepAlgoFeeds:Start': {}
+  'OnboardingV2:StepAlgoFeeds:End': {
+    selectedPrimaryFeeds: string[]
+    selectedPrimaryFeedsLength: number
+    selectedSecondaryFeeds: string[]
+    selectedSecondaryFeedsLength: number
+  }
+  'OnboardingV2:StepTopicalFeeds:Start': {}
+  'OnboardingV2:StepTopicalFeeds:End': {
+    selectedFeeds: string[]
+    selectedFeedsLength: number
+  }
+  'OnboardingV2:StepModeration:Start': {}
+  'OnboardingV2:StepModeration:End': {}
+  'OnboardingV2:StepFinished:Start': {}
+  'OnboardingV2:StepFinished:End': {}
+  'OnboardingV2:Complete': {}
+  'OnboardingV2:Skip': {}
 }
 
 interface ScreenPropertiesMap {
diff --git a/src/lib/build-flags.ts b/src/lib/build-flags.ts
index cf05114e0..d651887ff 100644
--- a/src/lib/build-flags.ts
+++ b/src/lib/build-flags.ts
@@ -1,2 +1,3 @@
 export const LOGIN_INCLUDE_DEV_SERVERS = true
 export const PWI_ENABLED = true
+export const NEW_ONBOARDING_ENABLED = false
diff --git a/src/screens/Onboarding/IconCircle.tsx b/src/screens/Onboarding/IconCircle.tsx
new file mode 100644
index 000000000..a54c8b4e4
--- /dev/null
+++ b/src/screens/Onboarding/IconCircle.tsx
@@ -0,0 +1,51 @@
+import React from 'react'
+import {View} from 'react-native'
+
+import {
+  useTheme,
+  atoms as a,
+  ViewStyleProp,
+  TextStyleProp,
+  flatten,
+} from '#/alf'
+import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth'
+import {Props} from '#/components/icons/common'
+
+export function IconCircle({
+  icon: Icon,
+  size = 'xl',
+  style,
+  iconStyle,
+}: ViewStyleProp & {
+  icon: typeof Growth
+  size?: Props['size']
+  iconStyle?: TextStyleProp['style']
+}) {
+  const t = useTheme()
+
+  return (
+    <View
+      style={[
+        a.justify_center,
+        a.align_center,
+        a.rounded_full,
+        {
+          width: 64,
+          height: 64,
+          backgroundColor:
+            t.name === 'light' ? t.palette.primary_50 : t.palette.primary_950,
+        },
+        flatten(style),
+      ]}>
+      <Icon
+        size={size}
+        style={[
+          {
+            color: t.palette.primary_500,
+          },
+          flatten(iconStyle),
+        ]}
+      />
+    </View>
+  )
+}
diff --git a/src/screens/Onboarding/Layout.tsx b/src/screens/Onboarding/Layout.tsx
new file mode 100644
index 000000000..50487c189
--- /dev/null
+++ b/src/screens/Onboarding/Layout.tsx
@@ -0,0 +1,231 @@
+import React from 'react'
+import {View} from 'react-native'
+import {useSafeAreaInsets} from 'react-native-safe-area-context'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+
+import {IS_DEV} from '#/env'
+import {isWeb} from '#/platform/detection'
+import {useOnboardingDispatch} from '#/state/shell'
+
+import {
+  useTheme,
+  atoms as a,
+  useBreakpoints,
+  web,
+  native,
+  flatten,
+  TextStyleProp,
+} from '#/alf'
+import {H2, P, leading} from '#/components/Typography'
+import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
+import {Button, ButtonIcon} from '#/components/Button'
+import {ScrollView} from '#/view/com/util/Views'
+import {createPortalGroup} from '#/components/Portal'
+
+import {Context} from '#/screens/Onboarding/state'
+
+const COL_WIDTH = 500
+
+export const OnboardingControls = createPortalGroup()
+
+export function Layout({children}: React.PropsWithChildren<{}>) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const insets = useSafeAreaInsets()
+  const {gtMobile} = useBreakpoints()
+  const onboardDispatch = useOnboardingDispatch()
+  const {state, dispatch} = React.useContext(Context)
+  const scrollview = React.useRef<ScrollView>(null)
+  const prevActiveStep = React.useRef<string>(state.activeStep)
+
+  React.useEffect(() => {
+    if (state.activeStep !== prevActiveStep.current) {
+      prevActiveStep.current = state.activeStep
+      scrollview.current?.scrollTo({y: 0, animated: false})
+    }
+  }, [state])
+
+  const paddingTop = gtMobile ? a.py_5xl : a.py_lg
+  const dialogLabel = _(msg`Set up your account`)
+
+  return (
+    <View
+      aria-modal
+      role="dialog"
+      aria-role="dialog"
+      aria-label={dialogLabel}
+      accessibilityLabel={dialogLabel}
+      accessibilityHint={_(
+        msg`The following steps will help customize your Bluesky experience.`,
+      )}
+      style={[
+        // @ts-ignore web only -prf
+        isWeb ? a.fixed : a.absolute,
+        a.inset_0,
+        a.flex_1,
+        t.atoms.bg,
+      ]}>
+      {IS_DEV && (
+        <View style={[a.absolute, a.p_xl, a.z_10, {right: 0, top: insets.top}]}>
+          <Button
+            variant="ghost"
+            color="negative"
+            size="small"
+            onPress={() => onboardDispatch({type: 'skip'})}
+            // DEV ONLY
+            label="Clear onboarding state">
+            Clear
+          </Button>
+        </View>
+      )}
+
+      {!gtMobile && state.hasPrev && (
+        <View
+          style={[
+            web(a.fixed),
+            native(a.absolute),
+            a.flex_row,
+            a.w_full,
+            a.justify_center,
+            a.z_20,
+            a.px_xl,
+            {
+              top: paddingTop.paddingTop + insets.top - 1,
+            },
+          ]}>
+          <View style={[a.w_full, a.align_start, {maxWidth: COL_WIDTH}]}>
+            <Button
+              key={state.activeStep} // remove focus state on nav
+              variant="ghost"
+              color="secondary"
+              size="small"
+              shape="round"
+              label={_(msg`Go back to previous step`)}
+              style={[a.absolute]}
+              onPress={() => dispatch({type: 'prev'})}>
+              <ButtonIcon icon={ChevronLeft} />
+            </Button>
+          </View>
+        </View>
+      )}
+
+      <ScrollView
+        ref={scrollview}
+        style={[a.h_full, a.w_full, {paddingTop: insets.top}]}
+        contentContainerStyle={{borderWidth: 0}}
+        // @ts-ignore web only --prf
+        dataSet={{'stable-gutters': 1}}>
+        <View
+          style={[a.flex_row, a.justify_center, gtMobile ? a.px_5xl : a.px_xl]}>
+          <View style={[a.flex_1, {maxWidth: COL_WIDTH}]}>
+            <View style={[a.w_full, a.align_center, paddingTop]}>
+              <View
+                style={[
+                  a.flex_row,
+                  a.gap_sm,
+                  a.w_full,
+                  {paddingTop: 17, maxWidth: '60%'},
+                ]}>
+                {Array(state.totalSteps)
+                  .fill(0)
+                  .map((_, i) => (
+                    <View
+                      key={i}
+                      style={[
+                        a.flex_1,
+                        a.pt_xs,
+                        a.rounded_full,
+                        t.atoms.bg_contrast_50,
+                        {
+                          backgroundColor:
+                            i + 1 <= state.activeStepIndex
+                              ? t.palette.primary_500
+                              : t.palette.contrast_100,
+                        },
+                      ]}
+                    />
+                  ))}
+              </View>
+            </View>
+
+            <View
+              style={[a.w_full, a.mb_5xl, {paddingTop: gtMobile ? 20 : 40}]}>
+              {children}
+            </View>
+
+            <View style={{height: 200}} />
+          </View>
+        </View>
+      </ScrollView>
+
+      <View
+        style={[
+          // @ts-ignore web only -prf
+          isWeb ? a.fixed : a.absolute,
+          {bottom: 0, left: 0, right: 0},
+          t.atoms.bg,
+          t.atoms.border,
+          a.border_t,
+          a.align_center,
+          gtMobile ? a.px_5xl : a.px_xl,
+          isWeb
+            ? a.py_2xl
+            : {
+                paddingTop: a.pt_lg.paddingTop,
+                paddingBottom: insets.bottom,
+              },
+        ]}>
+        <View
+          style={[
+            a.w_full,
+            {maxWidth: COL_WIDTH},
+            gtMobile && [a.flex_row, a.justify_between],
+          ]}>
+          {gtMobile &&
+            (state.hasPrev ? (
+              <Button
+                key={state.activeStep} // remove focus state on nav
+                variant="solid"
+                color="secondary"
+                size="large"
+                shape="round"
+                label={_(msg`Go back to previous step`)}
+                onPress={() => dispatch({type: 'prev'})}>
+                <ButtonIcon icon={ChevronLeft} />
+              </Button>
+            ) : (
+              <View style={{height: 54}} />
+            ))}
+          <OnboardingControls.Outlet />
+        </View>
+      </View>
+    </View>
+  )
+}
+
+export function Title({
+  children,
+  style,
+}: React.PropsWithChildren<TextStyleProp>) {
+  return (
+    <H2
+      style={[
+        a.pb_sm,
+        {
+          lineHeight: leading(a.text_4xl, a.leading_tight),
+        },
+        flatten(style),
+      ]}>
+      {children}
+    </H2>
+  )
+}
+
+export function Description({
+  children,
+  style,
+}: React.PropsWithChildren<TextStyleProp>) {
+  const t = useTheme()
+  return <P style={[t.atoms.text_contrast_700, flatten(style)]}>{children}</P>
+}
diff --git a/src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx b/src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx
new file mode 100644
index 000000000..c7f1e6e4d
--- /dev/null
+++ b/src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx
@@ -0,0 +1,378 @@
+import React from 'react'
+import {View} from 'react-native'
+import LinearGradient from 'react-native-linear-gradient'
+import {Image} from 'expo-image'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+
+import {useTheme, atoms as a} from '#/alf'
+import * as Toggle from '#/components/forms/Toggle'
+import {useFeedSourceInfoQuery, FeedSourceInfo} from '#/state/queries/feed'
+import {Text, H3} from '#/components/Typography'
+import {RichText} from '#/components/RichText'
+
+import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import {FeedConfig} from '#/screens/Onboarding/StepAlgoFeeds'
+
+function PrimaryFeedCardInner({
+  feed,
+  config,
+}: {
+  feed: FeedSourceInfo
+  config: FeedConfig
+}) {
+  const t = useTheme()
+  const ctx = Toggle.useItemContext()
+
+  const styles = React.useMemo(
+    () => ({
+      active: [t.atoms.bg_contrast_25],
+      selected: [
+        a.shadow_md,
+        {
+          backgroundColor:
+            t.name === 'light' ? t.palette.primary_50 : t.palette.primary_950,
+        },
+      ],
+      selectedHover: [
+        {
+          backgroundColor:
+            t.name === 'light' ? t.palette.primary_25 : t.palette.primary_975,
+        },
+      ],
+      textSelected: [{color: t.palette.white}],
+      checkboxSelected: [
+        {
+          borderColor: t.palette.white,
+        },
+      ],
+    }),
+    [t],
+  )
+
+  return (
+    <View
+      style={[
+        a.relative,
+        a.w_full,
+        a.p_lg,
+        a.rounded_md,
+        a.overflow_hidden,
+        t.atoms.bg_contrast_50,
+        (ctx.hovered || ctx.focused || ctx.pressed) && styles.active,
+        ctx.selected && styles.selected,
+        ctx.selected &&
+          (ctx.hovered || ctx.focused || ctx.pressed) &&
+          styles.selectedHover,
+      ]}>
+      {ctx.selected && config.gradient && (
+        <LinearGradient
+          colors={config.gradient.values.map(v => v[1])}
+          locations={config.gradient.values.map(v => v[0])}
+          start={{x: 0, y: 0}}
+          end={{x: 1, y: 1}}
+          style={[a.absolute, a.inset_0]}
+        />
+      )}
+
+      <View style={[a.flex_row, a.align_center, a.justify_between, a.gap_lg]}>
+        <View
+          style={[
+            {
+              width: 64,
+              height: 64,
+            },
+            a.rounded_sm,
+            a.overflow_hidden,
+            t.atoms.bg,
+          ]}>
+          <Image
+            source={{uri: feed.avatar}}
+            style={[a.w_full, a.h_full]}
+            accessibilityIgnoresInvertColors
+          />
+        </View>
+
+        <View style={[a.pt_xs, a.flex_grow]}>
+          <H3
+            style={[
+              a.text_lg,
+              a.font_bold,
+              ctx.selected && styles.textSelected,
+            ]}>
+            {feed.displayName}
+          </H3>
+
+          <Text
+            style={[
+              {opacity: 0.6},
+              a.text_md,
+              a.py_xs,
+              ctx.selected && styles.textSelected,
+            ]}>
+            by @{feed.creatorHandle}
+          </Text>
+        </View>
+
+        <View
+          style={[
+            {
+              width: 28,
+              height: 28,
+            },
+            a.justify_center,
+            a.align_center,
+            a.rounded_sm,
+            ctx.selected ? [a.border, styles.checkboxSelected] : t.atoms.bg,
+          ]}>
+          {ctx.selected && <Check size="sm" fill={t.palette.white} />}
+        </View>
+      </View>
+
+      <View
+        style={[
+          {
+            opacity: ctx.selected ? 0.3 : 1,
+            borderTopWidth: 1,
+          },
+          a.mt_md,
+          a.w_full,
+          t.name === 'light' ? t.atoms.border : t.atoms.border_contrast,
+          ctx.selected && {
+            borderTopColor: t.palette.white,
+          },
+        ]}
+      />
+
+      <View style={[a.pt_md]}>
+        <RichText
+          value={feed.description}
+          style={[
+            a.text_md,
+            ctx.selected &&
+              (t.name === 'light'
+                ? t.atoms.text_inverted
+                : {color: t.palette.white}),
+          ]}
+          disableLinks
+        />
+      </View>
+    </View>
+  )
+}
+
+export function PrimaryFeedCard({config}: {config: FeedConfig}) {
+  const {_} = useLingui()
+  const {data: feed} = useFeedSourceInfoQuery({uri: config.uri})
+
+  return !feed ? (
+    <FeedCardPlaceholder primary />
+  ) : (
+    <Toggle.Item
+      name={feed.uri}
+      label={_(msg`Subscribe to the ${feed.displayName} feed`)}>
+      <PrimaryFeedCardInner config={config} feed={feed} />
+    </Toggle.Item>
+  )
+}
+
+function FeedCardInner({feed}: {feed: FeedSourceInfo; config: FeedConfig}) {
+  const t = useTheme()
+  const ctx = Toggle.useItemContext()
+
+  const styles = React.useMemo(
+    () => ({
+      active: [t.atoms.bg_contrast_25],
+      selected: [
+        {
+          backgroundColor:
+            t.name === 'light' ? t.palette.primary_50 : t.palette.primary_950,
+        },
+      ],
+      selectedHover: [
+        {
+          backgroundColor:
+            t.name === 'light' ? t.palette.primary_25 : t.palette.primary_975,
+        },
+      ],
+      textSelected: [],
+      checkboxSelected: [
+        {
+          backgroundColor: t.palette.primary_500,
+        },
+      ],
+    }),
+    [t],
+  )
+
+  return (
+    <View
+      style={[
+        a.relative,
+        a.w_full,
+        a.p_md,
+        a.rounded_md,
+        a.overflow_hidden,
+        t.atoms.bg_contrast_50,
+        (ctx.hovered || ctx.focused || ctx.pressed) && styles.active,
+        ctx.selected && styles.selected,
+        ctx.selected &&
+          (ctx.hovered || ctx.focused || ctx.pressed) &&
+          styles.selectedHover,
+      ]}>
+      <View style={[a.flex_row, a.align_center, a.justify_between, a.gap_lg]}>
+        <View
+          style={[
+            {
+              width: 44,
+              height: 44,
+            },
+            a.rounded_sm,
+            a.overflow_hidden,
+            t.atoms.bg,
+          ]}>
+          <Image
+            source={{uri: feed.avatar}}
+            style={[a.w_full, a.h_full]}
+            accessibilityIgnoresInvertColors
+          />
+        </View>
+
+        <View style={[a.pt_2xs, a.flex_grow]}>
+          <H3
+            style={[
+              a.text_md,
+              a.font_bold,
+              ctx.selected && styles.textSelected,
+            ]}>
+            {feed.displayName}
+          </H3>
+          <Text
+            style={[
+              {opacity: 0.8},
+              a.pt_xs,
+              ctx.selected && styles.textSelected,
+            ]}>
+            @{feed.creatorHandle}
+          </Text>
+        </View>
+
+        <View
+          style={[
+            a.justify_center,
+            a.align_center,
+            a.rounded_sm,
+            t.atoms.bg,
+            ctx.selected && styles.checkboxSelected,
+            {
+              width: 28,
+              height: 28,
+            },
+          ]}>
+          {ctx.selected && <Check size="sm" fill={t.palette.white} />}
+        </View>
+      </View>
+
+      <View
+        style={[
+          {
+            opacity: ctx.selected ? 0.3 : 1,
+            borderTopWidth: 1,
+          },
+          a.mt_md,
+          a.w_full,
+          t.name === 'light' ? t.atoms.border : t.atoms.border_contrast,
+          ctx.selected && {
+            borderTopColor: t.palette.primary_200,
+          },
+        ]}
+      />
+
+      <View style={[a.pt_md]}>
+        <RichText value={feed.description} disableLinks />
+      </View>
+    </View>
+  )
+}
+
+export function FeedCard({config}: {config: FeedConfig}) {
+  const {_} = useLingui()
+  const {data: feed} = useFeedSourceInfoQuery({uri: config.uri})
+
+  return !feed ? (
+    <FeedCardPlaceholder />
+  ) : feed.avatar ? (
+    <Toggle.Item
+      name={feed.uri}
+      label={_(msg`Subscribe to the ${feed.displayName} feed`)}>
+      <FeedCardInner config={config} feed={feed} />
+    </Toggle.Item>
+  ) : null
+}
+
+export function FeedCardPlaceholder({primary}: {primary?: boolean}) {
+  const t = useTheme()
+  return (
+    <View
+      style={[
+        a.relative,
+        a.w_full,
+        a.p_md,
+        a.rounded_md,
+        a.overflow_hidden,
+        t.atoms.bg_contrast_25,
+      ]}>
+      <View style={[a.flex_row, a.align_center, a.justify_between, a.gap_lg]}>
+        <View
+          style={[
+            {
+              width: primary ? 64 : 44,
+              height: primary ? 64 : 44,
+            },
+            a.rounded_sm,
+            a.overflow_hidden,
+            t.atoms.bg_contrast_100,
+          ]}
+        />
+
+        <View style={[a.pt_2xs, a.flex_grow, a.gap_sm]}>
+          <View
+            style={[
+              {width: 100, height: primary ? 20 : 16},
+              a.rounded_sm,
+              t.atoms.bg_contrast_100,
+            ]}
+          />
+          <View
+            style={[
+              {width: 60, height: 12},
+              a.rounded_sm,
+              t.atoms.bg_contrast_100,
+            ]}
+          />
+        </View>
+      </View>
+
+      <View
+        style={[
+          {
+            borderTopWidth: 1,
+          },
+          a.mt_md,
+          a.w_full,
+          t.atoms.border,
+        ]}
+      />
+
+      <View style={[a.pt_md, a.gap_xs]}>
+        <View
+          style={[
+            {width: '60%', height: 12},
+            a.rounded_sm,
+            t.atoms.bg_contrast_100,
+          ]}
+        />
+      </View>
+    </View>
+  )
+}
diff --git a/src/screens/Onboarding/StepAlgoFeeds/index.tsx b/src/screens/Onboarding/StepAlgoFeeds/index.tsx
new file mode 100644
index 000000000..4920c5ad7
--- /dev/null
+++ b/src/screens/Onboarding/StepAlgoFeeds/index.tsx
@@ -0,0 +1,160 @@
+import React from 'react'
+import {View} from 'react-native'
+import {useLingui} from '@lingui/react'
+import {msg, Trans} from '@lingui/macro'
+
+import {atoms as a, tokens, useTheme} from '#/alf'
+import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import * as Toggle from '#/components/forms/Toggle'
+import {Text} from '#/components/Typography'
+import {Loader} from '#/components/Loader'
+import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle'
+import {useAnalytics} from '#/lib/analytics/analytics'
+
+import {Context} from '#/screens/Onboarding/state'
+import {
+  Title,
+  Description,
+  OnboardingControls,
+} from '#/screens/Onboarding/Layout'
+import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard'
+import {IconCircle} from '#/screens/Onboarding/IconCircle'
+
+export type FeedConfig = {
+  default: boolean
+  uri: string
+  gradient?: typeof tokens.gradients.midnight | typeof tokens.gradients.nordic
+}
+
+const PRIMARY_FEEDS: FeedConfig[] = [
+  {
+    default: true,
+    uri: 'at://did:plc:wqowuobffl66jv3kpsvo7ak4/app.bsky.feed.generator/the-algorithm',
+    gradient: tokens.gradients.midnight,
+  },
+  {
+    default: false,
+    uri: 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot',
+    gradient: tokens.gradients.midnight,
+  },
+]
+
+const SECONDARY_FEEDS: FeedConfig[] = [
+  {
+    default: false,
+    uri: 'at://did:plc:vpkhqolt662uhesyj6nxm7ys/app.bsky.feed.generator/infreq',
+  },
+  {
+    default: false,
+    uri: 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/with-friends',
+  },
+  {
+    default: false,
+    uri: 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/best-of-follows',
+  },
+  {
+    default: false,
+    uri: 'at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/catch-up',
+  },
+  {
+    default: false,
+    uri: 'at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/at-bangers',
+  },
+]
+
+export function StepAlgoFeeds() {
+  const {_} = useLingui()
+  const {track} = useAnalytics()
+  const t = useTheme()
+  const {state, dispatch} = React.useContext(Context)
+  const [primaryFeedUris, setPrimaryFeedUris] = React.useState<string[]>(
+    PRIMARY_FEEDS.map(f => (f.default ? f.uri : '')).filter(Boolean),
+  )
+  const [secondaryFeedUris, setSeconaryFeedUris] = React.useState<string[]>([])
+  const [saving, setSaving] = React.useState(false)
+
+  const saveFeeds = React.useCallback(async () => {
+    setSaving(true)
+
+    const uris = primaryFeedUris.concat(secondaryFeedUris)
+    dispatch({type: 'setAlgoFeedsStepResults', feedUris: uris})
+
+    setSaving(false)
+    dispatch({type: 'next'})
+    track('OnboardingV2:StepAlgoFeeds:End', {
+      selectedPrimaryFeeds: primaryFeedUris,
+      selectedPrimaryFeedsLength: primaryFeedUris.length,
+      selectedSecondaryFeeds: secondaryFeedUris,
+      selectedSecondaryFeedsLength: secondaryFeedUris.length,
+    })
+  }, [primaryFeedUris, secondaryFeedUris, dispatch, track])
+
+  React.useEffect(() => {
+    track('OnboardingV2:StepAlgoFeeds:Start')
+  }, [track])
+
+  return (
+    <View style={[a.align_start]}>
+      <IconCircle icon={ListSparkle} style={[a.mb_2xl]} />
+
+      <Title>
+        <Trans>Choose your algorithmic feeds</Trans>
+      </Title>
+      <Description>
+        <Trans>
+          Feeds are created by users and can give you entirely new experiences.
+        </Trans>
+      </Description>
+
+      <View style={[a.w_full, a.pb_2xl]}>
+        <Toggle.Group
+          values={primaryFeedUris}
+          onChange={setPrimaryFeedUris}
+          label={_(msg`Select your primary algorithmic feeds`)}>
+          <Text
+            style={[a.text_md, a.pt_4xl, a.pb_md, t.atoms.text_contrast_700]}>
+            <Trans>We recommend "For You" by Skygaze:</Trans>
+          </Text>
+          <FeedCard config={PRIMARY_FEEDS[0]} />
+          <Text
+            style={[a.text_md, a.pt_4xl, a.pb_lg, t.atoms.text_contrast_700]}>
+            <Trans>Or you can try our "Discover" algorithm:</Trans>
+          </Text>
+          <FeedCard config={PRIMARY_FEEDS[1]} />
+        </Toggle.Group>
+
+        <Toggle.Group
+          values={secondaryFeedUris}
+          onChange={setSeconaryFeedUris}
+          label={_(msg`Select your secondary algorithmic feeds`)}>
+          <Text
+            style={[a.text_md, a.pt_4xl, a.pb_lg, t.atoms.text_contrast_700]}>
+            <Trans>There are many feeds to try:</Trans>
+          </Text>
+          <View style={[a.gap_md]}>
+            {SECONDARY_FEEDS.map(config => (
+              <FeedCard key={config.uri} config={config} />
+            ))}
+          </View>
+        </Toggle.Group>
+      </View>
+
+      <OnboardingControls.Portal>
+        <Button
+          disabled={saving}
+          key={state.activeStep} // remove focus state on nav
+          variant="gradient"
+          color="gradient_sky"
+          size="large"
+          label={_(msg`Continue to the next step`)}
+          onPress={saveFeeds}>
+          <ButtonText>
+            <Trans>Continue</Trans>
+          </ButtonText>
+          <ButtonIcon icon={saving ? Loader : ChevronRight} position="right" />
+        </Button>
+      </OnboardingControls.Portal>
+    </View>
+  )
+}
diff --git a/src/screens/Onboarding/StepFinished.tsx b/src/screens/Onboarding/StepFinished.tsx
new file mode 100644
index 000000000..02c45f590
--- /dev/null
+++ b/src/screens/Onboarding/StepFinished.tsx
@@ -0,0 +1,158 @@
+import React from 'react'
+import {View} from 'react-native'
+import {useLingui} from '@lingui/react'
+import {msg, Trans} from '@lingui/macro'
+
+import {logger} from '#/logger'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonText, ButtonIcon} from '#/components/Button'
+import {News2_Stroke2_Corner0_Rounded as News} from '#/components/icons/News2'
+import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth'
+import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending2'
+import {Text} from '#/components/Typography'
+import {useOnboardingDispatch} from '#/state/shell'
+import {Loader} from '#/components/Loader'
+import {useSetSaveFeedsMutation} from '#/state/queries/preferences'
+import {getAgent} from '#/state/session'
+import {useAnalytics} from '#/lib/analytics/analytics'
+
+import {Context} from '#/screens/Onboarding/state'
+import {
+  Title,
+  Description,
+  OnboardingControls,
+} from '#/screens/Onboarding/Layout'
+import {IconCircle} from '#/screens/Onboarding/IconCircle'
+import {
+  bulkWriteFollows,
+  sortPrimaryAlgorithmFeeds,
+} from '#/screens/Onboarding/util'
+
+export function StepFinished() {
+  const {_} = useLingui()
+  const t = useTheme()
+  const {track} = useAnalytics()
+  const {state, dispatch} = React.useContext(Context)
+  const onboardDispatch = useOnboardingDispatch()
+  const [saving, setSaving] = React.useState(false)
+  const {mutateAsync: saveFeeds} = useSetSaveFeedsMutation()
+
+  const finishOnboarding = React.useCallback(async () => {
+    setSaving(true)
+
+    const {
+      interestsStepResults,
+      suggestedAccountsStepResults,
+      algoFeedsStepResults,
+      topicalFeedsStepResults,
+    } = state
+    const {selectedInterests} = interestsStepResults
+    const selectedFeeds = [
+      ...sortPrimaryAlgorithmFeeds(algoFeedsStepResults.feedUris),
+      ...topicalFeedsStepResults.feedUris,
+    ]
+
+    try {
+      await Promise.all([
+        bulkWriteFollows(suggestedAccountsStepResults.accountDids),
+        // these must be serial
+        (async () => {
+          await getAgent().setInterestsPref({tags: selectedInterests})
+          await saveFeeds({
+            saved: selectedFeeds,
+            pinned: selectedFeeds,
+          })
+        })(),
+      ])
+    } catch (e: any) {
+      logger.info(`onboarding: bulk save failed`)
+      logger.error(e)
+      // don't alert the user, just let them into their account
+    }
+
+    setSaving(false)
+    dispatch({type: 'finish'})
+    onboardDispatch({type: 'finish'})
+    track('OnboardingV2:StepFinished:End')
+    track('OnboardingV2:Complete')
+  }, [state, dispatch, onboardDispatch, setSaving, saveFeeds, track])
+
+  React.useEffect(() => {
+    track('OnboardingV2:StepFinished:Start')
+  }, [track])
+
+  return (
+    <View style={[a.align_start]}>
+      <IconCircle icon={Check} style={[a.mb_2xl]} />
+
+      <Title>
+        <Trans>You're ready to go!</Trans>
+      </Title>
+      <Description>
+        <Trans>We hope you have a wonderful time. Remember, Bluesky is:</Trans>
+      </Description>
+
+      <View style={[a.pt_5xl, a.gap_3xl]}>
+        <View style={[a.flex_row, a.align_center, a.w_full, a.gap_lg]}>
+          <IconCircle icon={Growth} size="lg" style={{width: 48, height: 48}} />
+          <View style={[a.flex_1, a.gap_xs]}>
+            <Text style={[a.font_bold, a.text_lg]}>
+              <Trans>Public</Trans>
+            </Text>
+            <Text
+              style={[t.atoms.text_contrast_500, a.text_md, a.leading_snug]}>
+              <Trans>
+                Your posts, likes, and blocks are public. Mutes are private.
+              </Trans>
+            </Text>
+          </View>
+        </View>
+        <View style={[a.flex_row, a.align_center, a.w_full, a.gap_lg]}>
+          <IconCircle icon={News} size="lg" style={{width: 48, height: 48}} />
+          <View style={[a.flex_1, a.gap_xs]}>
+            <Text style={[a.font_bold, a.text_lg]}>
+              <Trans>Open</Trans>
+            </Text>
+            <Text
+              style={[t.atoms.text_contrast_500, a.text_md, a.leading_snug]}>
+              <Trans>Never lose access to your followers and data.</Trans>
+            </Text>
+          </View>
+        </View>
+        <View style={[a.flex_row, a.align_center, a.w_full, a.gap_lg]}>
+          <IconCircle
+            icon={Trending}
+            size="lg"
+            style={{width: 48, height: 48}}
+          />
+          <View style={[a.flex_1, a.gap_xs]}>
+            <Text style={[a.font_bold, a.text_lg]}>
+              <Trans>Flexible</Trans>
+            </Text>
+            <Text
+              style={[t.atoms.text_contrast_500, a.text_md, a.leading_snug]}>
+              <Trans>Choose the algorithms that power your custom feeds.</Trans>
+            </Text>
+          </View>
+        </View>
+      </View>
+
+      <OnboardingControls.Portal>
+        <Button
+          disabled={saving}
+          key={state.activeStep} // remove focus state on nav
+          variant="gradient"
+          color="gradient_sky"
+          size="large"
+          label={_(msg`Complete onboarding and start using your account`)}
+          onPress={finishOnboarding}>
+          <ButtonText>
+            {saving ? <Trans>Finalizing</Trans> : <Trans>Let's go!</Trans>}
+          </ButtonText>
+          {saving && <ButtonIcon icon={Loader} position="right" />}
+        </Button>
+      </OnboardingControls.Portal>
+    </View>
+  )
+}
diff --git a/src/screens/Onboarding/StepFollowingFeed.tsx b/src/screens/Onboarding/StepFollowingFeed.tsx
new file mode 100644
index 000000000..4b3c62889
--- /dev/null
+++ b/src/screens/Onboarding/StepFollowingFeed.tsx
@@ -0,0 +1,160 @@
+import React from 'react'
+import {View} from 'react-native'
+import {useLingui} from '@lingui/react'
+import {msg, Trans} from '@lingui/macro'
+
+import {atoms as a} from '#/alf'
+import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
+import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {Text} from '#/components/Typography'
+import {Divider} from '#/components/Divider'
+import * as Toggle from '#/components/forms/Toggle'
+import {useAnalytics} from '#/lib/analytics/analytics'
+
+import {Context} from '#/screens/Onboarding/state'
+import {
+  Title,
+  Description,
+  OnboardingControls,
+} from '#/screens/Onboarding/Layout'
+import {
+  usePreferencesQuery,
+  useSetFeedViewPreferencesMutation,
+} from 'state/queries/preferences'
+import {IconCircle} from '#/screens/Onboarding/IconCircle'
+
+export function StepFollowingFeed() {
+  const {_} = useLingui()
+  const {track} = useAnalytics()
+  const {dispatch} = React.useContext(Context)
+
+  const {data: preferences} = usePreferencesQuery()
+  const {mutate: setFeedViewPref, variables} =
+    useSetFeedViewPreferencesMutation()
+
+  const showReplies = !(
+    variables?.hideReplies ?? preferences?.feedViewPrefs.hideReplies
+  )
+  const showReposts = !(
+    variables?.hideReposts ?? preferences?.feedViewPrefs.hideReposts
+  )
+  const showQuotes = !(
+    variables?.hideQuotePosts ?? preferences?.feedViewPrefs.hideQuotePosts
+  )
+
+  const onContinue = React.useCallback(() => {
+    dispatch({type: 'next'})
+    track('OnboardingV2:StepFollowingFeed:End')
+  }, [track, dispatch])
+
+  React.useEffect(() => {
+    track('OnboardingV2:StepFollowingFeed:Start')
+  }, [track])
+
+  return (
+    // Hack for now to move the image container up
+    <View style={[a.align_start]}>
+      <IconCircle icon={FilterTimeline} style={[a.mb_2xl]} />
+
+      <Title>
+        <Trans>Your default feed is "Following"</Trans>
+      </Title>
+      <Description style={[a.mb_md]}>
+        <Trans>It show posts from the people your follow as they happen.</Trans>
+      </Description>
+
+      <View style={[a.w_full]}>
+        <Toggle.Item
+          name="Show Replies" // no need to translate
+          label={_(msg`Show replies in Following feed`)}
+          value={showReplies}
+          onChange={() => {
+            setFeedViewPref({
+              hideReplies: showReplies,
+            })
+          }}>
+          <View
+            style={[
+              a.flex_row,
+              a.w_full,
+              a.py_lg,
+              a.justify_between,
+              a.align_center,
+            ]}>
+            <Text style={[a.text_md, a.font_bold]}>
+              <Trans>Show replies in Following</Trans>
+            </Text>
+            <Toggle.Switch />
+          </View>
+        </Toggle.Item>
+        <Divider />
+        <Toggle.Item
+          name="Show Reposts" // no need to translate
+          label={_(msg`Show re-posts in Following feed`)}
+          value={showReposts}
+          onChange={() => {
+            setFeedViewPref({
+              hideReposts: showReposts,
+            })
+          }}>
+          <View
+            style={[
+              a.flex_row,
+              a.w_full,
+              a.py_lg,
+              a.justify_between,
+              a.align_center,
+            ]}>
+            <Text style={[a.text_md, a.font_bold]}>
+              <Trans>Show reposts in Following</Trans>
+            </Text>
+            <Toggle.Switch />
+          </View>
+        </Toggle.Item>
+        <Divider />
+        <Toggle.Item
+          name="Show Quotes" // no need to translate
+          label={_(msg`Show quote-posts in Following feed`)}
+          value={showQuotes}
+          onChange={() => {
+            setFeedViewPref({
+              hideQuotePosts: showQuotes,
+            })
+          }}>
+          <View
+            style={[
+              a.flex_row,
+              a.w_full,
+              a.py_lg,
+              a.justify_between,
+              a.align_center,
+            ]}>
+            <Text style={[a.text_md, a.font_bold]}>
+              <Trans>Show quotes in Following</Trans>
+            </Text>
+            <Toggle.Switch />
+          </View>
+        </Toggle.Item>
+      </View>
+
+      <Description style={[a.mt_lg]}>
+        <Trans>You can change these settings later.</Trans>
+      </Description>
+
+      <OnboardingControls.Portal>
+        <Button
+          variant="gradient"
+          color="gradient_sky"
+          size="large"
+          label={_(msg`Continue to next step`)}
+          onPress={onContinue}>
+          <ButtonText>
+            <Trans>Continue</Trans>
+          </ButtonText>
+          <ButtonIcon icon={ChevronRight} position="right" />
+        </Button>
+      </OnboardingControls.Portal>
+    </View>
+  )
+}
diff --git a/src/screens/Onboarding/StepInterests/InterestButton.tsx b/src/screens/Onboarding/StepInterests/InterestButton.tsx
new file mode 100644
index 000000000..02413b18d
--- /dev/null
+++ b/src/screens/Onboarding/StepInterests/InterestButton.tsx
@@ -0,0 +1,79 @@
+import React from 'react'
+import {View, ViewStyle, TextStyle} from 'react-native'
+
+import {useTheme, atoms as a, native} from '#/alf'
+import * as Toggle from '#/components/forms/Toggle'
+import {Text} from '#/components/Typography'
+
+import {INTEREST_TO_DISPLAY_NAME} from '#/screens/Onboarding/StepInterests/data'
+
+export function InterestButton({interest}: {interest: string}) {
+  const t = useTheme()
+  const ctx = Toggle.useItemContext()
+
+  const styles = React.useMemo(() => {
+    const hovered: ViewStyle[] = [
+      {
+        backgroundColor:
+          t.name === 'light' ? t.palette.contrast_200 : t.palette.contrast_50,
+      },
+    ]
+    const focused: ViewStyle[] = []
+    const pressed: ViewStyle[] = []
+    const selected: ViewStyle[] = [
+      {
+        backgroundColor: t.palette.contrast_900,
+      },
+    ]
+    const selectedHover: ViewStyle[] = [
+      {
+        backgroundColor: t.palette.contrast_800,
+      },
+    ]
+    const textSelected: TextStyle[] = [
+      {
+        color: t.palette.contrast_100,
+      },
+    ]
+
+    return {
+      hovered,
+      focused,
+      pressed,
+      selected,
+      selectedHover,
+      textSelected,
+    }
+  }, [t])
+
+  return (
+    <View
+      style={[
+        {
+          backgroundColor: t.palette.contrast_100,
+          paddingVertical: 15,
+        },
+        a.rounded_full,
+        a.px_2xl,
+        ctx.hovered ? styles.hovered : {},
+        ctx.focused ? styles.hovered : {},
+        ctx.pressed ? styles.hovered : {},
+        ctx.selected ? styles.selected : {},
+        ctx.selected && (ctx.hovered || ctx.focused || ctx.pressed)
+          ? styles.selectedHover
+          : {},
+      ]}>
+      <Text
+        style={[
+          {
+            color: t.palette.contrast_900,
+          },
+          a.font_bold,
+          native({paddingTop: 2}),
+          ctx.selected ? styles.textSelected : {},
+        ]}>
+        {INTEREST_TO_DISPLAY_NAME[interest]}
+      </Text>
+    </View>
+  )
+}
diff --git a/src/screens/Onboarding/StepInterests/data.ts b/src/screens/Onboarding/StepInterests/data.ts
new file mode 100644
index 000000000..00a25331c
--- /dev/null
+++ b/src/screens/Onboarding/StepInterests/data.ts
@@ -0,0 +1,36 @@
+export const INTEREST_TO_DISPLAY_NAME: {
+  [key: string]: string
+} = {
+  news: 'News',
+  journalism: 'Journalism',
+  nature: 'Nature',
+  art: 'Art',
+  comics: 'Comics',
+  writers: 'Writers',
+  culture: 'Culture',
+  sports: 'Sports',
+  pets: 'Pets',
+  animals: 'Animals',
+  books: 'Books',
+  education: 'Education',
+  climate: 'Climate',
+  science: 'Science',
+  politics: 'Politics',
+  fitness: 'Fitness',
+  tech: 'Tech',
+  dev: 'Software Dev',
+  comedy: 'Comedy',
+  gaming: 'Video Games',
+  food: 'Food',
+  cooking: 'Cooking',
+}
+
+export type ApiResponseMap = {
+  interests: string[]
+  suggestedAccountDids: {
+    [key: string]: string[]
+  }
+  suggestedFeedUris: {
+    [key: string]: string[]
+  }
+}
diff --git a/src/screens/Onboarding/StepInterests/index.tsx b/src/screens/Onboarding/StepInterests/index.tsx
new file mode 100644
index 000000000..6f60991d5
--- /dev/null
+++ b/src/screens/Onboarding/StepInterests/index.tsx
@@ -0,0 +1,260 @@
+import React from 'react'
+import {View} from 'react-native'
+import {useLingui} from '@lingui/react'
+import {msg, Trans} from '@lingui/macro'
+import {useQuery} from '@tanstack/react-query'
+
+import {logger} from '#/logger'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
+import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
+import {EmojiSad_Stroke2_Corner0_Rounded as EmojiSad} from '#/components/icons/Emoji'
+import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwise} from '#/components/icons/ArrowRotateCounterClockwise'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {Loader} from '#/components/Loader'
+import * as Toggle from '#/components/forms/Toggle'
+import {getAgent} from '#/state/session'
+import {useAnalytics} from '#/lib/analytics/analytics'
+import {Text} from '#/components/Typography'
+import {useOnboardingDispatch} from '#/state/shell'
+
+import {Context} from '#/screens/Onboarding/state'
+import {
+  Title,
+  Description,
+  OnboardingControls,
+} from '#/screens/Onboarding/Layout'
+import {
+  ApiResponseMap,
+  INTEREST_TO_DISPLAY_NAME,
+} from '#/screens/Onboarding/StepInterests/data'
+import {InterestButton} from '#/screens/Onboarding/StepInterests/InterestButton'
+import {IconCircle} from '#/screens/Onboarding/IconCircle'
+
+export function StepInterests() {
+  const {_} = useLingui()
+  const t = useTheme()
+  const {track} = useAnalytics()
+  const {gtMobile} = useBreakpoints()
+  const {state, dispatch} = React.useContext(Context)
+  const [saving, setSaving] = React.useState(false)
+  const [interests, setInterests] = React.useState<string[]>(
+    state.interestsStepResults.selectedInterests.map(i => i),
+  )
+  const onboardDispatch = useOnboardingDispatch()
+  const {isLoading, isError, error, data, refetch, isFetching} = useQuery({
+    queryKey: ['interests'],
+    queryFn: async () => {
+      try {
+        const {data} =
+          await getAgent().app.bsky.unspecced.getTaggedSuggestions()
+        return data.suggestions.reduce(
+          (agg, s) => {
+            const {tag, subject, subjectType} = s
+            const isDefault = tag === 'default'
+
+            if (!agg.interests.includes(tag) && !isDefault) {
+              agg.interests.push(tag)
+            }
+
+            if (subjectType === 'user') {
+              agg.suggestedAccountDids[tag] =
+                agg.suggestedAccountDids[tag] || []
+              agg.suggestedAccountDids[tag].push(subject)
+            }
+
+            if (subjectType === 'feed') {
+              // agg all feeds into defaults
+              if (isDefault) {
+                agg.suggestedFeedUris[tag] = agg.suggestedFeedUris[tag] || []
+              } else {
+                agg.suggestedFeedUris[tag] = agg.suggestedFeedUris[tag] || []
+                agg.suggestedFeedUris[tag].push(subject)
+                agg.suggestedFeedUris.default.push(subject)
+              }
+            }
+
+            return agg
+          },
+          {
+            interests: [],
+            suggestedAccountDids: {},
+            suggestedFeedUris: {},
+          } as ApiResponseMap,
+        )
+      } catch (e: any) {
+        logger.info(
+          `onboarding: getTaggedSuggestions fetch or processing failed`,
+        )
+        logger.error(e)
+        track('OnboardingV2:StepInterests:Error')
+
+        throw new Error(`a network error occurred`)
+      }
+    },
+  })
+
+  const saveInterests = React.useCallback(async () => {
+    setSaving(true)
+
+    try {
+      setSaving(false)
+      dispatch({
+        type: 'setInterestsStepResults',
+        apiResponse: data!,
+        selectedInterests: interests,
+      })
+      dispatch({type: 'next'})
+
+      track('OnboardingV2:StepInterests:End', {
+        selectedInterests: interests,
+        selectedInterestsLength: interests.length,
+      })
+    } catch (e: any) {
+      logger.info(`onboading: error saving interests`)
+      logger.error(e)
+    }
+  }, [interests, data, setSaving, dispatch, track])
+
+  const skipOnboarding = React.useCallback(() => {
+    onboardDispatch({type: 'finish'})
+    dispatch({type: 'finish'})
+    track('OnboardingV2:Skip')
+  }, [onboardDispatch, dispatch, track])
+
+  React.useEffect(() => {
+    track('OnboardingV2:Begin')
+    track('OnboardingV2:StepInterests:Start')
+  }, [track])
+
+  const title = isError ? (
+    <Trans>Oh no! Something went wrong.</Trans>
+  ) : (
+    <Trans>What are your interests?</Trans>
+  )
+  const description = isError ? (
+    <Trans>
+      We weren't able to connect. Please try again to continue setting up your
+      account. If it continues to fail, you can skip this flow.
+    </Trans>
+  ) : (
+    <Trans>We'll use this to help customize your experience.</Trans>
+  )
+
+  return (
+    <View style={[a.align_start]}>
+      <IconCircle
+        icon={isError ? EmojiSad : Hashtag}
+        style={[
+          a.mb_2xl,
+          isError
+            ? {
+                backgroundColor: t.palette.negative_50,
+              }
+            : {},
+        ]}
+        iconStyle={[
+          isError
+            ? {
+                color: t.palette.negative_900,
+              }
+            : {},
+        ]}
+      />
+
+      <Title>{title}</Title>
+      <Description>{description}</Description>
+
+      <View style={[a.w_full, a.pt_2xl]}>
+        {isLoading ? (
+          <Loader size="xl" />
+        ) : isError || !data ? (
+          <View
+            style={[
+              a.w_full,
+              a.p_lg,
+              a.rounded_md,
+              {
+                backgroundColor: t.palette.negative_50,
+              },
+            ]}>
+            <Text style={[a.text_md]}>
+              <Text
+                style={[
+                  a.text_md,
+                  a.font_bold,
+                  {
+                    color: t.palette.negative_900,
+                  },
+                ]}>
+                Error:{' '}
+              </Text>
+              {error?.message || 'an unknown error occurred'}
+            </Text>
+          </View>
+        ) : (
+          <Toggle.Group
+            values={interests}
+            onChange={setInterests}
+            label={_(msg`Select your interests from the options below`)}>
+            <View style={[a.flex_row, a.gap_md, a.flex_wrap]}>
+              {data.interests.map(interest => (
+                <Toggle.Item
+                  key={interest}
+                  name={interest}
+                  label={INTEREST_TO_DISPLAY_NAME[interest]}>
+                  <InterestButton interest={interest} />
+                </Toggle.Item>
+              ))}
+            </View>
+          </Toggle.Group>
+        )}
+      </View>
+
+      <OnboardingControls.Portal>
+        {isError ? (
+          <View style={[a.gap_md, gtMobile ? a.flex_row : a.flex_col]}>
+            <Button
+              disabled={isFetching}
+              variant="solid"
+              color="secondary"
+              size="large"
+              label={_(msg`Retry`)}
+              onPress={() => refetch()}>
+              <ButtonText>
+                <Trans>Retry</Trans>
+              </ButtonText>
+              <ButtonIcon icon={ArrowRotateCounterClockwise} position="right" />
+            </Button>
+            <Button
+              variant="outline"
+              color="secondary"
+              size="large"
+              label={_(msg`Skip this flow`)}
+              onPress={skipOnboarding}>
+              <ButtonText>
+                <Trans>Skip</Trans>
+              </ButtonText>
+            </Button>
+          </View>
+        ) : (
+          <Button
+            disabled={saving || !data}
+            variant="gradient"
+            color="gradient_sky"
+            size="large"
+            label={_(msg`Continue to next step`)}
+            onPress={saveInterests}>
+            <ButtonText>
+              <Trans>Continue</Trans>
+            </ButtonText>
+            <ButtonIcon
+              icon={saving ? Loader : ChevronRight}
+              position="right"
+            />
+          </Button>
+        )}
+      </OnboardingControls.Portal>
+    </View>
+  )
+}
diff --git a/src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx b/src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx
new file mode 100644
index 000000000..bc4c0387f
--- /dev/null
+++ b/src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx
@@ -0,0 +1,135 @@
+import React from 'react'
+import {View} from 'react-native'
+import {useLingui} from '@lingui/react'
+import {msg, Trans} from '@lingui/macro'
+
+import {isIOS} from '#/platform/detection'
+import * as Toast from '#/view/com/util/Toast'
+import {atoms as a, useTheme} from '#/alf'
+import {
+  usePreferencesQuery,
+  usePreferencesSetAdultContentMutation,
+} from '#/state/queries/preferences'
+import {logger} from '#/logger'
+import {Text} from '#/components/Typography'
+import {InlineLink} from '#/components/Link'
+import * as Toggle from '#/components/forms/Toggle'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
+
+function Card({children}: React.PropsWithChildren<{}>) {
+  const t = useTheme()
+  return (
+    <View
+      style={[
+        a.w_full,
+        a.flex_row,
+        a.align_center,
+        a.gap_sm,
+        a.px_lg,
+        a.py_md,
+        a.rounded_sm,
+        a.mb_md,
+        t.atoms.bg_contrast_50,
+      ]}>
+      {children}
+    </View>
+  )
+}
+
+export function AdultContentEnabledPref() {
+  const {_} = useLingui()
+  const t = useTheme()
+
+  // Reuse logic here form ContentFilteringSettings.tsx
+  const {data: preferences} = usePreferencesQuery()
+  const {mutate, variables} = usePreferencesSetAdultContentMutation()
+
+  const onToggleAdultContent = React.useCallback(async () => {
+    if (isIOS) return
+
+    try {
+      mutate({
+        enabled: !(variables?.enabled ?? preferences?.adultContentEnabled),
+      })
+    } catch (e) {
+      Toast.show(
+        _(msg`There was an issue syncing your preferences with the server`),
+      )
+      logger.error('Failed to update preferences with server', {error: e})
+    }
+  }, [variables, preferences, mutate, _])
+
+  if (!preferences) return null
+
+  if (isIOS) {
+    if (preferences?.adultContentEnabled === true) {
+      return null
+    } else {
+      return (
+        <Card>
+          <CircleInfo size="sm" fill={t.palette.contrast_500} />
+          <Text
+            style={[
+              a.flex_1,
+              t.atoms.text_contrast_700,
+              a.leading_snug,
+              {paddingTop: 1},
+            ]}>
+            <Trans>
+              Adult content can only be enabled via the Web at{' '}
+              <InlineLink style={[a.leading_snug]} to="https://bsky.app">
+                bsky.app
+              </InlineLink>
+              .
+            </Trans>
+          </Text>
+        </Card>
+      )
+    }
+  } else {
+    if (preferences?.userAge) {
+      if (preferences.userAge >= 18) {
+        return (
+          <View style={[a.w_full]}>
+            <Toggle.Item
+              name={_(msg`Enable adult content in your feeds`)}
+              label={_(msg`Enable adult content in your feeds`)}
+              value={variables?.enabled ?? preferences?.adultContentEnabled}
+              onChange={onToggleAdultContent}>
+              <View
+                style={[
+                  a.flex_row,
+                  a.w_full,
+                  a.justify_between,
+                  a.align_center,
+                  a.py_md,
+                ]}>
+                <Text style={[a.font_bold]}>Enable Adult Content</Text>
+                <Toggle.Switch />
+              </View>
+            </Toggle.Item>
+          </View>
+        )
+      } else {
+        return (
+          <Card>
+            <CircleInfo size="sm" fill={t.palette.contrast_500} />
+            <Text
+              style={[
+                a.flex_1,
+                t.atoms.text_contrast_700,
+                a.leading_snug,
+                {paddingTop: 1},
+              ]}>
+              <Trans>
+                You must be 18 years or older to enable adult content
+              </Trans>
+            </Text>
+          </Card>
+        )
+      }
+    }
+
+    return null
+  }
+}
diff --git a/src/screens/Onboarding/StepModeration/ModerationOption.tsx b/src/screens/Onboarding/StepModeration/ModerationOption.tsx
new file mode 100644
index 000000000..904c47299
--- /dev/null
+++ b/src/screens/Onboarding/StepModeration/ModerationOption.tsx
@@ -0,0 +1,85 @@
+import React from 'react'
+import {View} from 'react-native'
+import {LabelPreference} from '@atproto/api'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+
+import {
+  CONFIGURABLE_LABEL_GROUPS,
+  ConfigurableLabelGroup,
+  usePreferencesQuery,
+  usePreferencesSetContentLabelMutation,
+} from '#/state/queries/preferences'
+import {atoms as a, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
+import * as ToggleButton from '#/components/forms/ToggleButton'
+
+export function ModerationOption({
+  labelGroup,
+}: {
+  labelGroup: ConfigurableLabelGroup
+}) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const groupInfo = CONFIGURABLE_LABEL_GROUPS[labelGroup]
+  const {data: preferences} = usePreferencesQuery()
+  const {mutate, variables} = usePreferencesSetContentLabelMutation()
+  const visibility =
+    variables?.visibility ?? preferences?.contentLabels?.[labelGroup]
+
+  const onChange = React.useCallback(
+    (vis: string[]) => {
+      mutate({labelGroup, visibility: vis[0] as LabelPreference})
+    },
+    [mutate, labelGroup],
+  )
+
+  const labels = {
+    hide: _(msg`Hide`),
+    warn: _(msg`Warn`),
+    show: _(msg`Show`),
+  }
+
+  return (
+    <View
+      style={[
+        a.flex_row,
+        a.justify_between,
+        a.gap_sm,
+        a.py_xs,
+        a.px_xs,
+        a.align_center,
+      ]}>
+      <View style={[a.gap_xs, {width: '50%'}]}>
+        <Text style={[a.font_bold]}>{groupInfo.title}</Text>
+        <Text style={[t.atoms.text_contrast_700, a.leading_snug]}>
+          {groupInfo.subtitle}
+        </Text>
+      </View>
+      <View style={[a.justify_center, {minHeight: 35}]}>
+        {!preferences?.adultContentEnabled && groupInfo.isAdultImagery ? (
+          <View style={[a.justify_center, {minHeight: 40}]}>
+            <Text style={[a.font_bold]}>{labels.hide}</Text>
+          </View>
+        ) : (
+          <ToggleButton.Group
+            label={_(
+              msg`Configure content filtering setting for category: ${groupInfo.title.toLowerCase()}`,
+            )}
+            values={[visibility ?? 'hide']}
+            onChange={onChange}>
+            <ToggleButton.Button name="hide" label={labels.hide}>
+              {labels.hide}
+            </ToggleButton.Button>
+            <ToggleButton.Button name="warn" label={labels.warn}>
+              {labels.warn}
+            </ToggleButton.Button>
+            <ToggleButton.Button name="ignore" label={labels.show}>
+              {labels.show}
+            </ToggleButton.Button>
+          </ToggleButton.Group>
+        )}
+      </View>
+    </View>
+  )
+}
diff --git a/src/screens/Onboarding/StepModeration/index.tsx b/src/screens/Onboarding/StepModeration/index.tsx
new file mode 100644
index 000000000..be605e407
--- /dev/null
+++ b/src/screens/Onboarding/StepModeration/index.tsx
@@ -0,0 +1,91 @@
+import React from 'react'
+import {View} from 'react-native'
+import {useLingui} from '@lingui/react'
+import {msg, Trans} from '@lingui/macro'
+
+import {atoms as a} from '#/alf'
+import {configurableLabelGroups} from 'state/queries/preferences'
+import {Divider} from '#/components/Divider'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
+import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
+import {usePreferencesQuery} from '#/state/queries/preferences'
+import {Loader} from '#/components/Loader'
+import {useAnalytics} from '#/lib/analytics/analytics'
+
+import {
+  Description,
+  OnboardingControls,
+  Title,
+} from '#/screens/Onboarding/Layout'
+import {ModerationOption} from '#/screens/Onboarding/StepModeration/ModerationOption'
+import {AdultContentEnabledPref} from '#/screens/Onboarding/StepModeration/AdultContentEnabledPref'
+import {Context} from '#/screens/Onboarding/state'
+import {IconCircle} from '#/screens/Onboarding/IconCircle'
+
+export function StepModeration() {
+  const {_} = useLingui()
+  const {track} = useAnalytics()
+  const {state, dispatch} = React.useContext(Context)
+  const {data: preferences} = usePreferencesQuery()
+
+  const onContinue = React.useCallback(() => {
+    dispatch({type: 'next'})
+    track('OnboardingV2:StepModeration:End')
+  }, [track, dispatch])
+
+  React.useEffect(() => {
+    track('OnboardingV2:StepModeration:Start')
+  }, [track])
+
+  return (
+    <View style={[a.align_start]}>
+      <IconCircle icon={EyeSlash} style={[a.mb_2xl]} />
+
+      <Title>
+        <Trans>You are in control</Trans>
+      </Title>
+      <Description style={[a.mb_xl]}>
+        <Trans>
+          Select the types of content that you want to see (or not see), and
+          we'll handle the rest.
+        </Trans>
+      </Description>
+
+      {!preferences ? (
+        <View style={[a.pt_md]}>
+          <Loader size="xl" />
+        </View>
+      ) : (
+        <>
+          <AdultContentEnabledPref />
+
+          <View style={[a.gap_sm, a.w_full]}>
+            {configurableLabelGroups.map((g, index) => (
+              <React.Fragment key={index}>
+                {index === 0 && <Divider />}
+                <ModerationOption labelGroup={g} />
+                <Divider />
+              </React.Fragment>
+            ))}
+          </View>
+        </>
+      )}
+
+      <OnboardingControls.Portal>
+        <Button
+          key={state.activeStep} // remove focus state on nav
+          variant="gradient"
+          color="gradient_sky"
+          size="large"
+          label={_(msg`Continue to next step`)}
+          onPress={onContinue}>
+          <ButtonText>
+            <Trans>Continue</Trans>
+          </ButtonText>
+          <ButtonIcon icon={ChevronRight} position="right" />
+        </Button>
+      </OnboardingControls.Portal>
+    </View>
+  )
+}
diff --git a/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx b/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx
new file mode 100644
index 000000000..bc707ce8f
--- /dev/null
+++ b/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx
@@ -0,0 +1,188 @@
+import React from 'react'
+import {View, ViewStyle} from 'react-native'
+import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
+
+import {useTheme, atoms as a, flatten} from '#/alf'
+import {Text} from '#/components/Typography'
+import {useItemContext} from '#/components/forms/Toggle'
+import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {useModerationOpts} from '#/state/queries/preferences'
+import {RichText} from '#/components/RichText'
+
+export function SuggestedAccountCard({
+  profile,
+  moderationOpts,
+}: {
+  profile: AppBskyActorDefs.ProfileViewDetailed
+  moderationOpts: ReturnType<typeof useModerationOpts>
+}) {
+  const t = useTheme()
+  const ctx = useItemContext()
+  const moderation = moderateProfile(profile, moderationOpts!)
+
+  const styles = React.useMemo(() => {
+    const light = t.name === 'light'
+    const base: ViewStyle[] = [t.atoms.bg_contrast_50]
+    const hover: ViewStyle[] = [t.atoms.bg_contrast_25]
+    const selected: ViewStyle[] = [
+      {
+        backgroundColor: light ? t.palette.primary_50 : t.palette.primary_950,
+      },
+    ]
+    const selectedHover: ViewStyle[] = [
+      {
+        backgroundColor: light ? t.palette.primary_25 : t.palette.primary_975,
+      },
+    ]
+    const checkboxBase: ViewStyle[] = [t.atoms.bg]
+    const checkboxSelected: ViewStyle[] = [
+      {
+        backgroundColor: t.palette.primary_500,
+      },
+    ]
+    const avatarBase: ViewStyle[] = [t.atoms.bg_contrast_100]
+    const avatarSelected: ViewStyle[] = [
+      {
+        backgroundColor: light ? t.palette.primary_100 : t.palette.primary_900,
+      },
+    ]
+
+    return {
+      base,
+      hover: flatten(hover),
+      selected: flatten(selected),
+      selectedHover: flatten(selectedHover),
+      checkboxBase: flatten(checkboxBase),
+      checkboxSelected: flatten(checkboxSelected),
+      avatarBase: flatten(avatarBase),
+      avatarSelected: flatten(avatarSelected),
+    }
+  }, [t])
+
+  return (
+    <View
+      style={[
+        a.w_full,
+        a.p_md,
+        a.pr_lg,
+        a.gap_md,
+        a.rounded_md,
+        styles.base,
+        (ctx.hovered || ctx.focused || ctx.pressed) && styles.hover,
+        ctx.selected && styles.selected,
+        ctx.selected &&
+          (ctx.hovered || ctx.focused || ctx.pressed) &&
+          styles.selectedHover,
+      ]}>
+      <View style={[a.flex_row, a.align_center, a.justify_between, a.gap_lg]}>
+        <View style={[a.flex_row, a.flex_1, a.align_center, a.gap_md]}>
+          <View
+            style={[
+              {width: 48, height: 48},
+              a.relative,
+              a.rounded_full,
+              styles.avatarBase,
+              ctx.selected && styles.avatarSelected,
+            ]}>
+            <UserAvatar
+              size={48}
+              avatar={profile.avatar}
+              moderation={moderation.avatar}
+            />
+          </View>
+          <View style={[a.flex_1]}>
+            <Text style={[a.font_bold, a.text_md, a.pb_xs]} numberOfLines={1}>
+              {profile.displayName}
+            </Text>
+            <Text style={[t.atoms.text_contrast_600]}>{profile.handle}</Text>
+          </View>
+        </View>
+
+        <View
+          style={[
+            a.justify_center,
+            a.align_center,
+            a.rounded_sm,
+            styles.checkboxBase,
+            ctx.selected && styles.checkboxSelected,
+            {
+              width: 28,
+              height: 28,
+            },
+          ]}>
+          {ctx.selected && <Check size="sm" fill={t.palette.white} />}
+        </View>
+      </View>
+
+      {profile.description && (
+        <>
+          <View
+            style={[
+              {
+                opacity: ctx.selected ? 0.3 : 1,
+                borderTopWidth: 1,
+              },
+              a.w_full,
+              t.name === 'light' ? t.atoms.border : t.atoms.border_contrast,
+              ctx.selected && {
+                borderTopColor: t.palette.primary_200,
+              },
+            ]}
+          />
+
+          <RichText
+            value={profile.description}
+            disableLinks
+            numberOfLines={2}
+          />
+        </>
+      )}
+    </View>
+  )
+}
+
+export function SuggestedAccountCardPlaceholder() {
+  const t = useTheme()
+  return (
+    <View
+      style={[
+        a.w_full,
+        a.flex_row,
+        a.justify_between,
+        a.align_center,
+        a.p_md,
+        a.pr_lg,
+        a.gap_xl,
+        a.rounded_md,
+        t.atoms.bg_contrast_25,
+      ]}>
+      <View style={[a.flex_row, a.align_center, a.gap_md]}>
+        <View
+          style={[
+            {width: 48, height: 48},
+            a.relative,
+            a.rounded_full,
+            t.atoms.bg_contrast_100,
+          ]}
+        />
+        <View style={[a.gap_xs]}>
+          <View
+            style={[
+              {width: 100, height: 16},
+              a.rounded_sm,
+              t.atoms.bg_contrast_100,
+            ]}
+          />
+          <View
+            style={[
+              {width: 60, height: 12},
+              a.rounded_sm,
+              t.atoms.bg_contrast_100,
+            ]}
+          />
+        </View>
+      </View>
+    </View>
+  )
+}
diff --git a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx
new file mode 100644
index 000000000..723e53a98
--- /dev/null
+++ b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx
@@ -0,0 +1,198 @@
+import React from 'react'
+import {View} from 'react-native'
+import {AppBskyActorDefs} from '@atproto/api'
+import {useLingui} from '@lingui/react'
+import {msg, Trans} from '@lingui/macro'
+
+import {atoms as a, useBreakpoints} from '#/alf'
+import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
+import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {Text} from '#/components/Typography'
+import {useProfilesQuery} from '#/state/queries/profile'
+import {Loader} from '#/components/Loader'
+import * as Toggle from '#/components/forms/Toggle'
+import {useModerationOpts} from '#/state/queries/preferences'
+import {useAnalytics} from '#/lib/analytics/analytics'
+
+import {Context} from '#/screens/Onboarding/state'
+import {
+  Title,
+  Description,
+  OnboardingControls,
+} from '#/screens/Onboarding/Layout'
+import {
+  SuggestedAccountCard,
+  SuggestedAccountCardPlaceholder,
+} from '#/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard'
+import {INTEREST_TO_DISPLAY_NAME} from '#/screens/Onboarding/StepInterests/data'
+import {aggregateInterestItems} from '#/screens/Onboarding/util'
+import {IconCircle} from '#/screens/Onboarding/IconCircle'
+
+export function Inner({
+  profiles,
+  onSelect,
+  moderationOpts,
+}: {
+  profiles: AppBskyActorDefs.ProfileViewDetailed[]
+  onSelect: (dids: string[]) => void
+  moderationOpts: ReturnType<typeof useModerationOpts>
+}) {
+  const {_} = useLingui()
+  const [dids, setDids] = React.useState<string[]>(profiles.map(p => p.did))
+
+  React.useEffect(() => {
+    onSelect(dids)
+  }, [dids, onSelect])
+
+  return (
+    <Toggle.Group
+      values={dids}
+      onChange={setDids}
+      label={_(msg`Select some accounts below to follow`)}>
+      <View style={[a.gap_md]}>
+        {profiles.map(profile => (
+          <Toggle.Item
+            key={profile.did}
+            name={profile.did}
+            label={_(msg`Follow ${profile.handle}`)}>
+            <SuggestedAccountCard
+              profile={profile}
+              moderationOpts={moderationOpts}
+            />
+          </Toggle.Item>
+        ))}
+      </View>
+    </Toggle.Group>
+  )
+}
+
+export function StepSuggestedAccounts() {
+  const {_} = useLingui()
+  const {track} = useAnalytics()
+  const {state, dispatch} = React.useContext(Context)
+  const {gtMobile} = useBreakpoints()
+  const suggestedDids = React.useMemo(() => {
+    return aggregateInterestItems(
+      state.interestsStepResults.selectedInterests,
+      state.interestsStepResults.apiResponse.suggestedAccountDids,
+      state.interestsStepResults.apiResponse.suggestedAccountDids.default,
+    )
+  }, [state.interestsStepResults])
+  const moderationOpts = useModerationOpts()
+  const {
+    isLoading: isProfilesLoading,
+    isError,
+    data,
+    error,
+  } = useProfilesQuery({
+    handles: suggestedDids,
+  })
+  const [dids, setDids] = React.useState<string[]>([])
+  const [saving, setSaving] = React.useState(false)
+
+  const interestsText = React.useMemo(() => {
+    const i = state.interestsStepResults.selectedInterests.map(
+      i => INTEREST_TO_DISPLAY_NAME[i],
+    )
+    return i.join(', ')
+  }, [state.interestsStepResults.selectedInterests])
+
+  const handleContinue = React.useCallback(async () => {
+    setSaving(true)
+
+    if (dids.length) {
+      dispatch({type: 'setSuggestedAccountsStepResults', accountDids: dids})
+    }
+
+    setSaving(false)
+    dispatch({type: 'next'})
+    track('OnboardingV2:StepSuggestedAccounts:Start', {
+      selectedAccountsLength: dids.length,
+    })
+  }, [dids, setSaving, dispatch, track])
+
+  const handleSkip = React.useCallback(() => {
+    // if a user comes back and clicks skip, erase follows
+    dispatch({type: 'setSuggestedAccountsStepResults', accountDids: []})
+    dispatch({type: 'next'})
+  }, [dispatch])
+
+  const isLoading = isProfilesLoading && moderationOpts
+
+  React.useEffect(() => {
+    track('OnboardingV2:StepSuggestedAccounts:Start')
+  }, [track])
+
+  return (
+    <View style={[a.align_start]}>
+      <IconCircle icon={At} style={[a.mb_2xl]} />
+
+      <Title>
+        <Trans>Here are some accounts for your to follow</Trans>
+      </Title>
+      <Description>
+        {state.interestsStepResults.selectedInterests.length ? (
+          <Trans>Based on your interest in {interestsText}</Trans>
+        ) : (
+          <Trans>These are popular accounts you might like.</Trans>
+        )}
+      </Description>
+
+      <View style={[a.w_full, a.pt_xl]}>
+        {isLoading ? (
+          <View style={[a.gap_md]}>
+            {Array(10)
+              .fill(0)
+              .map((_, i) => (
+                <SuggestedAccountCardPlaceholder key={i} />
+              ))}
+          </View>
+        ) : isError || !data ? (
+          <Text>{error?.toString()}</Text>
+        ) : (
+          <Inner
+            profiles={data.profiles}
+            onSelect={setDids}
+            moderationOpts={moderationOpts}
+          />
+        )}
+      </View>
+
+      <OnboardingControls.Portal>
+        <View
+          style={[
+            a.gap_md,
+            gtMobile ? {flexDirection: 'row-reverse'} : a.flex_col,
+          ]}>
+          <Button
+            disabled={dids.length === 0}
+            variant="gradient"
+            color="gradient_sky"
+            size="large"
+            label={_(
+              msg`Follow selected accounts and continue to then next step`,
+            )}
+            onPress={handleContinue}>
+            <ButtonText>
+              <Trans>Follow All</Trans>
+            </ButtonText>
+            <ButtonIcon icon={saving ? Loader : Plus} position="right" />
+          </Button>
+          <Button
+            variant="solid"
+            color="secondary"
+            size="large"
+            label={_(
+              msg`Continue to the next step without following any accounts`,
+            )}
+            onPress={handleSkip}>
+            <ButtonText>
+              <Trans>Skip</Trans>
+            </ButtonText>
+          </Button>
+        </View>
+      </OnboardingControls.Portal>
+    </View>
+  )
+}
diff --git a/src/screens/Onboarding/StepTopicalFeeds.tsx b/src/screens/Onboarding/StepTopicalFeeds.tsx
new file mode 100644
index 000000000..516c18e6e
--- /dev/null
+++ b/src/screens/Onboarding/StepTopicalFeeds.tsx
@@ -0,0 +1,113 @@
+import React from 'react'
+import {View} from 'react-native'
+import {useLingui} from '@lingui/react'
+import {msg, Trans} from '@lingui/macro'
+
+import {atoms as a} from '#/alf'
+import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
+import {ListMagnifyingGlass_Stroke2_Corner0_Rounded as ListMagnifyingGlass} from '#/components/icons/ListMagnifyingGlass'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import * as Toggle from '#/components/forms/Toggle'
+import {Loader} from '#/components/Loader'
+import {useAnalytics} from '#/lib/analytics/analytics'
+
+import {Context} from '#/screens/Onboarding/state'
+import {
+  Title,
+  Description,
+  OnboardingControls,
+} from '#/screens/Onboarding/Layout'
+import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard'
+import {INTEREST_TO_DISPLAY_NAME} from '#/screens/Onboarding/StepInterests/data'
+import {aggregateInterestItems} from '#/screens/Onboarding/util'
+import {IconCircle} from '#/screens/Onboarding/IconCircle'
+
+export function StepTopicalFeeds() {
+  const {_} = useLingui()
+  const {track} = useAnalytics()
+  const {state, dispatch} = React.useContext(Context)
+  const [selectedFeedUris, setSelectedFeedUris] = React.useState<string[]>([])
+  const [saving, setSaving] = React.useState(false)
+  const suggestedFeedUris = React.useMemo(() => {
+    return aggregateInterestItems(
+      state.interestsStepResults.selectedInterests,
+      state.interestsStepResults.apiResponse.suggestedFeedUris,
+      state.interestsStepResults.apiResponse.suggestedFeedUris.default,
+    ).slice(0, 10)
+  }, [state.interestsStepResults])
+
+  const interestsText = React.useMemo(() => {
+    const i = state.interestsStepResults.selectedInterests.map(
+      i => INTEREST_TO_DISPLAY_NAME[i],
+    )
+    return i.join(', ')
+  }, [state.interestsStepResults.selectedInterests])
+
+  const saveFeeds = React.useCallback(async () => {
+    setSaving(true)
+
+    dispatch({type: 'setTopicalFeedsStepResults', feedUris: selectedFeedUris})
+
+    setSaving(false)
+    dispatch({type: 'next'})
+    track('OnboardingV2:StepTopicalFeeds:End', {
+      selectedFeeds: selectedFeedUris,
+      selectedFeedsLength: selectedFeedUris.length,
+    })
+  }, [selectedFeedUris, dispatch, track])
+
+  React.useEffect(() => {
+    track('OnboardingV2:StepTopicalFeeds:Start')
+  }, [track])
+
+  return (
+    <View style={[a.align_start]}>
+      <IconCircle icon={ListMagnifyingGlass} style={[a.mb_2xl]} />
+
+      <Title>
+        <Trans>Feeds can be topical as well!</Trans>
+      </Title>
+      <Description>
+        {state.interestsStepResults.selectedInterests.length ? (
+          <Trans>
+            Here are some topical feeds based on your interests: {interestsText}
+            . You can choose to follow as many as you like.
+          </Trans>
+        ) : (
+          <Trans>
+            Here are some popular topical feeds. You can choose to follow as
+            many as you like.
+          </Trans>
+        )}
+      </Description>
+
+      <View style={[a.w_full, a.pb_2xl, a.pt_2xl]}>
+        <Toggle.Group
+          values={selectedFeedUris}
+          onChange={setSelectedFeedUris}
+          label={_(msg`Select topical feeds to follow from the list below`)}>
+          <View style={[a.gap_md]}>
+            {suggestedFeedUris.map(uri => (
+              <FeedCard key={uri} config={{default: false, uri}} />
+            ))}
+          </View>
+        </Toggle.Group>
+      </View>
+
+      <OnboardingControls.Portal>
+        <Button
+          key={state.activeStep} // remove focus state on nav
+          variant="gradient"
+          color="gradient_sky"
+          size="large"
+          label={_(msg`Continue to next step`)}
+          onPress={saveFeeds}>
+          <ButtonText>
+            <Trans>Continue</Trans>
+          </ButtonText>
+          <ButtonIcon icon={saving ? Loader : ChevronRight} position="right" />
+        </Button>
+      </OnboardingControls.Portal>
+    </View>
+  )
+}
diff --git a/src/screens/Onboarding/index.tsx b/src/screens/Onboarding/index.tsx
new file mode 100644
index 000000000..a4eb04012
--- /dev/null
+++ b/src/screens/Onboarding/index.tsx
@@ -0,0 +1,38 @@
+import React from 'react'
+
+import {Portal} from '#/components/Portal'
+
+import {Context, initialState, reducer} from '#/screens/Onboarding/state'
+import {Layout, OnboardingControls} from '#/screens/Onboarding/Layout'
+import {StepInterests} from '#/screens/Onboarding/StepInterests'
+import {StepSuggestedAccounts} from '#/screens/Onboarding/StepSuggestedAccounts'
+import {StepFollowingFeed} from '#/screens/Onboarding/StepFollowingFeed'
+import {StepAlgoFeeds} from '#/screens/Onboarding/StepAlgoFeeds'
+import {StepTopicalFeeds} from '#/screens/Onboarding/StepTopicalFeeds'
+import {StepFinished} from '#/screens/Onboarding/StepFinished'
+import {StepModeration} from '#/screens/Onboarding/StepModeration'
+
+export function Onboarding() {
+  const [state, dispatch] = React.useReducer(reducer, {...initialState})
+
+  return (
+    <Portal>
+      <OnboardingControls.Provider>
+        <Context.Provider
+          value={React.useMemo(() => ({state, dispatch}), [state, dispatch])}>
+          <Layout>
+            {state.activeStep === 'interests' && <StepInterests />}
+            {state.activeStep === 'suggestedAccounts' && (
+              <StepSuggestedAccounts />
+            )}
+            {state.activeStep === 'followingFeed' && <StepFollowingFeed />}
+            {state.activeStep === 'algoFeeds' && <StepAlgoFeeds />}
+            {state.activeStep === 'topicalFeeds' && <StepTopicalFeeds />}
+            {state.activeStep === 'moderation' && <StepModeration />}
+            {state.activeStep === 'finished' && <StepFinished />}
+          </Layout>
+        </Context.Provider>
+      </OnboardingControls.Provider>
+    </Portal>
+  )
+}
diff --git a/src/screens/Onboarding/state.ts b/src/screens/Onboarding/state.ts
new file mode 100644
index 000000000..164c2f5f3
--- /dev/null
+++ b/src/screens/Onboarding/state.ts
@@ -0,0 +1,201 @@
+import React from 'react'
+
+import {ApiResponseMap} from '#/screens/Onboarding/StepInterests/data'
+import {logger} from '#/logger'
+
+export type OnboardingState = {
+  hasPrev: boolean
+  totalSteps: number
+  activeStep:
+    | 'interests'
+    | 'suggestedAccounts'
+    | 'followingFeed'
+    | 'algoFeeds'
+    | 'topicalFeeds'
+    | 'moderation'
+    | 'finished'
+  activeStepIndex: number
+
+  interestsStepResults: {
+    selectedInterests: string[]
+    apiResponse: ApiResponseMap
+  }
+  suggestedAccountsStepResults: {
+    accountDids: string[]
+  }
+  algoFeedsStepResults: {
+    feedUris: string[]
+  }
+  topicalFeedsStepResults: {
+    feedUris: string[]
+  }
+}
+
+export type OnboardingAction =
+  | {
+      type: 'next'
+    }
+  | {
+      type: 'prev'
+    }
+  | {
+      type: 'finish'
+    }
+  | {
+      type: 'setInterestsStepResults'
+      selectedInterests: string[]
+      apiResponse: ApiResponseMap
+    }
+  | {
+      type: 'setSuggestedAccountsStepResults'
+      accountDids: string[]
+    }
+  | {
+      type: 'setAlgoFeedsStepResults'
+      feedUris: string[]
+    }
+  | {
+      type: 'setTopicalFeedsStepResults'
+      feedUris: string[]
+    }
+
+export const initialState: OnboardingState = {
+  hasPrev: false,
+  totalSteps: 7,
+  activeStep: 'interests',
+  activeStepIndex: 1,
+
+  interestsStepResults: {
+    selectedInterests: [],
+    apiResponse: {
+      interests: [],
+      suggestedAccountDids: {},
+      suggestedFeedUris: {},
+    },
+  },
+  suggestedAccountsStepResults: {
+    accountDids: [],
+  },
+  algoFeedsStepResults: {
+    feedUris: [],
+  },
+  topicalFeedsStepResults: {
+    feedUris: [],
+  },
+}
+
+export const Context = React.createContext<{
+  state: OnboardingState
+  dispatch: React.Dispatch<OnboardingAction>
+}>({
+  state: {...initialState},
+  dispatch: () => {},
+})
+
+export function reducer(
+  s: OnboardingState,
+  a: OnboardingAction,
+): OnboardingState {
+  let next = {...s}
+
+  switch (a.type) {
+    case 'next': {
+      if (s.activeStep === 'interests') {
+        next.activeStep = 'suggestedAccounts'
+        next.activeStepIndex = 2
+      } else if (s.activeStep === 'suggestedAccounts') {
+        next.activeStep = 'followingFeed'
+        next.activeStepIndex = 3
+      } else if (s.activeStep === 'followingFeed') {
+        next.activeStep = 'algoFeeds'
+        next.activeStepIndex = 4
+      } else if (s.activeStep === 'algoFeeds') {
+        next.activeStep = 'topicalFeeds'
+        next.activeStepIndex = 5
+      } else if (s.activeStep === 'topicalFeeds') {
+        next.activeStep = 'moderation'
+        next.activeStepIndex = 6
+      } else if (s.activeStep === 'moderation') {
+        next.activeStep = 'finished'
+        next.activeStepIndex = 7
+      }
+      break
+    }
+    case 'prev': {
+      if (s.activeStep === 'suggestedAccounts') {
+        next.activeStep = 'interests'
+        next.activeStepIndex = 1
+      } else if (s.activeStep === 'followingFeed') {
+        next.activeStep = 'suggestedAccounts'
+        next.activeStepIndex = 2
+      } else if (s.activeStep === 'algoFeeds') {
+        next.activeStep = 'followingFeed'
+        next.activeStepIndex = 3
+      } else if (s.activeStep === 'topicalFeeds') {
+        next.activeStep = 'algoFeeds'
+        next.activeStepIndex = 4
+      } else if (s.activeStep === 'moderation') {
+        next.activeStep = 'topicalFeeds'
+        next.activeStepIndex = 5
+      } else if (s.activeStep === 'finished') {
+        next.activeStep = 'moderation'
+        next.activeStepIndex = 6
+      }
+      break
+    }
+    case 'finish': {
+      next = initialState
+      break
+    }
+    case 'setInterestsStepResults': {
+      next.interestsStepResults = {
+        selectedInterests: a.selectedInterests,
+        apiResponse: a.apiResponse,
+      }
+      break
+    }
+    case 'setSuggestedAccountsStepResults': {
+      next.suggestedAccountsStepResults = {
+        accountDids: next.suggestedAccountsStepResults.accountDids.concat(
+          a.accountDids,
+        ),
+      }
+      break
+    }
+    case 'setAlgoFeedsStepResults': {
+      next.algoFeedsStepResults = {
+        feedUris: a.feedUris,
+      }
+      break
+    }
+    case 'setTopicalFeedsStepResults': {
+      next.topicalFeedsStepResults = {
+        feedUris: next.topicalFeedsStepResults.feedUris.concat(a.feedUris),
+      }
+      break
+    }
+  }
+
+  const state = {
+    ...next,
+    hasPrev: next.activeStep !== 'interests',
+  }
+
+  logger.debug(`onboarding`, {
+    hasPrev: state.hasPrev,
+    activeStep: state.activeStep,
+    activeStepIndex: state.activeStepIndex,
+    interestsStepResults: {
+      selectedInterests: state.interestsStepResults.selectedInterests,
+    },
+    suggestedAccountsStepResults: state.suggestedAccountsStepResults,
+    algoFeedsStepResults: state.algoFeedsStepResults,
+    topicalFeedsStepResults: state.topicalFeedsStepResults,
+  })
+
+  if (s.activeStep !== state.activeStep) {
+    logger.info(`onboarding: step changed`, {activeStep: state.activeStep})
+  }
+
+  return state
+}
diff --git a/src/screens/Onboarding/util.ts b/src/screens/Onboarding/util.ts
new file mode 100644
index 000000000..2a709a67b
--- /dev/null
+++ b/src/screens/Onboarding/util.ts
@@ -0,0 +1,112 @@
+import {AppBskyGraphFollow, AppBskyGraphGetFollows} from '@atproto/api'
+
+import {until} from '#/lib/async/until'
+import {getAgent} from '#/state/session'
+
+function shuffle(array: any) {
+  let currentIndex = array.length,
+    randomIndex
+
+  // While there remain elements to shuffle.
+  while (currentIndex > 0) {
+    // Pick a remaining element.
+    randomIndex = Math.floor(Math.random() * currentIndex)
+    currentIndex--
+
+    // And swap it with the current element.
+    ;[array[currentIndex], array[randomIndex]] = [
+      array[randomIndex],
+      array[currentIndex],
+    ]
+  }
+
+  return array
+}
+
+export function aggregateInterestItems(
+  interests: string[],
+  map: {[key: string]: string[]},
+  fallbackItems: string[],
+) {
+  const selected = interests.length
+  const all = interests
+    .map(i => {
+      const suggestions = shuffle(map[i])
+
+      if (selected === 1) {
+        return suggestions // return all
+      } else if (selected === 2) {
+        return suggestions.slice(0, 5) // return 5
+      } else {
+        return suggestions.slice(0, 3) // return 3
+      }
+    })
+    .flat()
+  // dedupe suggestions
+  const results = Array.from(new Set(all))
+
+  // backfill
+  if (results.length < 20) {
+    results.push(...shuffle(fallbackItems))
+  }
+
+  // dedupe and return 20
+  return Array.from(new Set(results)).slice(0, 20)
+}
+
+export async function bulkWriteFollows(dids: string[]) {
+  const session = getAgent().session
+
+  if (!session) {
+    throw new Error(`bulkWriteFollows failed: no session`)
+  }
+
+  const followRecords: AppBskyGraphFollow.Record[] = dids.map(did => {
+    return {
+      $type: 'app.bsky.graph.follow',
+      subject: did,
+      createdAt: new Date().toISOString(),
+    }
+  })
+  const followWrites = followRecords.map(r => ({
+    $type: 'com.atproto.repo.applyWrites#create',
+    collection: 'app.bsky.graph.follow',
+    value: r,
+  }))
+
+  await getAgent().com.atproto.repo.applyWrites({
+    repo: session.did,
+    writes: followWrites,
+  })
+  await whenFollowsIndexed(session.did, res => !!res.data.follows.length)
+}
+
+async function whenFollowsIndexed(
+  actor: string,
+  fn: (res: AppBskyGraphGetFollows.Response) => boolean,
+) {
+  await until(
+    5, // 5 tries
+    1e3, // 1s delay between tries
+    fn,
+    () =>
+      getAgent().app.bsky.graph.getFollows({
+        actor,
+        limit: 1,
+      }),
+  )
+}
+
+/**
+ * Kinda hacky, but we want For Your or Discover to appear as the first pinned
+ * feed after Following
+ */
+export function sortPrimaryAlgorithmFeeds(uris: string[]) {
+  return uris.sort(uri => {
+    return uri.includes('the-algorithm')
+      ? -1
+      : uri.includes('whats-hot')
+      ? 0
+      : 1
+  })
+}
diff --git a/src/state/queries/preferences/const.ts b/src/state/queries/preferences/const.ts
index b7f9206e8..2d9d02994 100644
--- a/src/state/queries/preferences/const.ts
+++ b/src/state/queries/preferences/const.ts
@@ -48,4 +48,5 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
   feedViewPrefs: DEFAULT_HOME_FEED_PREFS,
   threadViewPrefs: DEFAULT_THREAD_VIEW_PREFS,
   userAge: 13, // TODO(pwi)
+  interests: {tags: []},
 }
diff --git a/src/state/queries/preferences/types.ts b/src/state/queries/preferences/types.ts
index 5fca8d558..cd9a2e8f9 100644
--- a/src/state/queries/preferences/types.ts
+++ b/src/state/queries/preferences/types.ts
@@ -5,14 +5,17 @@ import {
   BskyFeedViewPreference,
 } from '@atproto/api'
 
-export type ConfigurableLabelGroup =
-  | 'nsfw'
-  | 'nudity'
-  | 'suggestive'
-  | 'gore'
-  | 'hate'
-  | 'spam'
-  | 'impersonation'
+export const configurableLabelGroups = [
+  'nsfw',
+  'nudity',
+  'suggestive',
+  'gore',
+  'hate',
+  'spam',
+  'impersonation',
+] as const
+export type ConfigurableLabelGroup = (typeof configurableLabelGroups)[number]
+
 export type LabelGroup =
   | ConfigurableLabelGroup
   | 'illegal'
diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts
index 434269183..e6203550f 100644
--- a/src/state/queries/profile.ts
+++ b/src/state/queries/profile.ts
@@ -24,6 +24,7 @@ import {STALE} from '#/state/queries'
 import {track} from '#/lib/analytics/analytics'
 
 export const RQKEY = (did: string) => ['profile', did]
+export const profilesQueryKey = (handles: string[]) => ['profiles', handles]
 
 export function useProfileQuery({did}: {did: string | undefined}) {
   const {currentAccount} = useSession()
@@ -45,6 +46,17 @@ export function useProfileQuery({did}: {did: string | undefined}) {
   })
 }
 
+export function useProfilesQuery({handles}: {handles: string[]}) {
+  return useQuery({
+    staleTime: STALE.MINUTES.FIVE,
+    queryKey: profilesQueryKey(handles),
+    queryFn: async () => {
+      const res = await getAgent().getProfiles({actors: handles})
+      return res.data
+    },
+  })
+}
+
 interface ProfileUpdateParams {
   profile: AppBskyActorDefs.ProfileView
   updates:
diff --git a/src/view/screens/Storybook/Buttons.tsx b/src/view/screens/Storybook/Buttons.tsx
index fbdc84eb4..320db13ff 100644
--- a/src/view/screens/Storybook/Buttons.tsx
+++ b/src/view/screens/Storybook/Buttons.tsx
@@ -11,6 +11,7 @@ import {
 } from '#/components/Button'
 import {H1} from '#/components/Typography'
 import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRight} from '#/components/icons/ArrowTopRight'
+import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
 import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
 
 export function Buttons() {
@@ -91,14 +92,16 @@ export function Buttons() {
             )}
           </View>
         </View>
+      </View>
 
+      <View style={[a.flex_wrap, a.gap_md, a.align_start]}>
         <Button
           variant="gradient"
           color="gradient_sky"
           size="large"
           label="Link out">
           <ButtonText>Link out</ButtonText>
-          <ButtonIcon icon={ArrowTopRight} />
+          <ButtonIcon icon={ArrowTopRight} position="right" />
         </Button>
 
         <Button
@@ -107,7 +110,15 @@ export function Buttons() {
           size="small"
           label="Link out">
           <ButtonText>Link out</ButtonText>
-          <ButtonIcon icon={ArrowTopRight} />
+          <ButtonIcon icon={ArrowTopRight} position="right" />
+        </Button>
+
+        <Button
+          variant="gradient"
+          color="gradient_sky"
+          size="small"
+          label="Link out">
+          <ButtonText>Link xxxxxx</ButtonText>
         </Button>
 
         <Button
@@ -115,8 +126,78 @@ export function Buttons() {
           color="gradient_sky"
           size="small"
           label="Link out">
-          <ButtonIcon icon={Globe} />
-          <ButtonText>See the world</ButtonText>
+          <ButtonIcon icon={Globe} position="left" />
+          <ButtonText>Link out</ButtonText>
+        </Button>
+      </View>
+
+      <View style={[a.flex_row, a.gap_md, a.align_start]}>
+        <Button
+          variant="solid"
+          color="primary"
+          size="large"
+          shape="round"
+          label="Link out">
+          <ButtonIcon icon={ChevronLeft} />
+        </Button>
+        <Button
+          variant="gradient"
+          color="gradient_sunset"
+          size="small"
+          shape="round"
+          label="Link out">
+          <ButtonIcon icon={ChevronLeft} />
+        </Button>
+        <Button
+          variant="outline"
+          color="primary"
+          size="large"
+          shape="round"
+          label="Link out">
+          <ButtonIcon icon={ChevronLeft} />
+        </Button>
+        <Button
+          variant="ghost"
+          color="primary"
+          size="small"
+          shape="round"
+          label="Link out">
+          <ButtonIcon icon={ChevronLeft} />
+        </Button>
+      </View>
+
+      <View style={[a.flex_row, a.gap_md, a.align_start]}>
+        <Button
+          variant="solid"
+          color="primary"
+          size="large"
+          shape="square"
+          label="Link out">
+          <ButtonIcon icon={ChevronLeft} />
+        </Button>
+        <Button
+          variant="gradient"
+          color="gradient_sunset"
+          size="small"
+          shape="square"
+          label="Link out">
+          <ButtonIcon icon={ChevronLeft} />
+        </Button>
+        <Button
+          variant="outline"
+          color="primary"
+          size="large"
+          shape="square"
+          label="Link out">
+          <ButtonIcon icon={ChevronLeft} />
+        </Button>
+        <Button
+          variant="ghost"
+          color="primary"
+          size="small"
+          shape="square"
+          label="Link out">
+          <ButtonIcon icon={ChevronLeft} />
         </Button>
       </View>
     </View>
diff --git a/src/view/screens/Storybook/Forms.tsx b/src/view/screens/Storybook/Forms.tsx
index 9396cca67..2d5495d70 100644
--- a/src/view/screens/Storybook/Forms.tsx
+++ b/src/view/screens/Storybook/Forms.tsx
@@ -209,6 +209,23 @@ export function Forms() {
             Show
           </ToggleButton.Button>
         </ToggleButton.Group>
+
+        <View>
+          <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>
     </View>
   )
diff --git a/src/view/screens/Storybook/Links.tsx b/src/view/screens/Storybook/Links.tsx
index c3b1c0e0f..245a61a05 100644
--- a/src/view/screens/Storybook/Links.tsx
+++ b/src/view/screens/Storybook/Links.tsx
@@ -1,38 +1,39 @@
 import React from 'react'
 import {View} from 'react-native'
 
-import {atoms as a} from '#/alf'
+import {useTheme, atoms as a} from '#/alf'
 import {ButtonText} from '#/components/Button'
-import {Link} from '#/components/Link'
-import {H1, H3} from '#/components/Typography'
+import {InlineLink, Link} from '#/components/Link'
+import {H1, H3, Text} from '#/components/Typography'
 
 export function Links() {
+  const t = useTheme()
   return (
     <View style={[a.gap_md, a.align_start]}>
       <H1>Links</H1>
 
       <View style={[a.gap_md, a.align_start]}>
-        <Link
+        <InlineLink
           to="https://blueskyweb.xyz"
           warnOnMismatchingTextChild
           style={[a.text_md]}>
           External
-        </Link>
-        <Link to="https://blueskyweb.xyz" style={[a.text_md]}>
+        </InlineLink>
+        <InlineLink to="https://blueskyweb.xyz" style={[a.text_md]}>
           <H3>External with custom children</H3>
-        </Link>
-        <Link
+        </InlineLink>
+        <InlineLink
           to="https://blueskyweb.xyz"
           warnOnMismatchingTextChild
           style={[a.text_lg]}>
           https://blueskyweb.xyz
-        </Link>
-        <Link
+        </InlineLink>
+        <InlineLink
           to="https://bsky.app/profile/bsky.app"
           warnOnMismatchingTextChild
           style={[a.text_md]}>
           Internal
-        </Link>
+        </InlineLink>
 
         <Link
           variant="solid"
@@ -42,6 +43,29 @@ export function Links() {
           to="https://bsky.app/profile/bsky.app">
           <ButtonText>Link as a button</ButtonText>
         </Link>
+
+        <Link
+          label="View @bsky.app's profile"
+          to="https://bsky.app/profile/bsky.app">
+          <View
+            style={[
+              a.flex_row,
+              a.align_center,
+              a.gap_md,
+              a.rounded_md,
+              a.p_md,
+              t.atoms.bg_contrast_25,
+            ]}>
+            <View
+              style={[
+                {width: 32, height: 32},
+                a.rounded_full,
+                t.atoms.bg_contrast_200,
+              ]}
+            />
+            <Text>View @bsky.app's profile</Text>
+          </View>
+        </Link>
       </View>
     </View>
   )
diff --git a/src/view/screens/Storybook/Typography.tsx b/src/view/screens/Storybook/Typography.tsx
index 2e1f04a66..ecd6ec888 100644
--- a/src/view/screens/Storybook/Typography.tsx
+++ b/src/view/screens/Storybook/Typography.tsx
@@ -3,6 +3,7 @@ import {View} from 'react-native'
 
 import {atoms as a} from '#/alf'
 import {Text, H1, H2, H3, H4, H5, H6, P} from '#/components/Typography'
+import {RichText} from '#/components/RichText'
 
 export function Typography() {
   return (
@@ -25,6 +26,16 @@ export function Typography() {
       <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>
+
+      <RichText
+        resolveFacets
+        value={`This is rich text. It can have mentions like @bsky.app or links like https://blueskyweb.xyz`}
+      />
+      <RichText
+        resolveFacets
+        value={`This is rich text. It can have mentions like @bsky.app or links like https://blueskyweb.xyz`}
+        style={[a.text_xl]}
+      />
     </View>
   )
 }
diff --git a/src/view/shell/createNativeStackNavigatorWithAuth.tsx b/src/view/shell/createNativeStackNavigatorWithAuth.tsx
index 7e275502b..0f240ea00 100644
--- a/src/view/shell/createNativeStackNavigatorWithAuth.tsx
+++ b/src/view/shell/createNativeStackNavigatorWithAuth.tsx
@@ -1,6 +1,6 @@
 import * as React from 'react'
 import {View} from 'react-native'
-import {PWI_ENABLED} from '#/lib/build-flags'
+import {PWI_ENABLED, NEW_ONBOARDING_ENABLED} from '#/lib/build-flags'
 
 // Based on @react-navigation/native-stack/src/createNativeStackNavigator.ts
 // MIT License
@@ -38,6 +38,7 @@ import {isWeb} from 'platform/detection'
 import {Deactivated} from '#/screens/Deactivated'
 import {LoggedOut} from '../com/auth/LoggedOut'
 import {Onboarding} from '../com/auth/Onboarding'
+import {Onboarding as NewOnboarding} from '#/screens/Onboarding'
 
 type NativeStackNavigationOptionsWithAuth = NativeStackNavigationOptions & {
   requireAuth?: boolean
@@ -111,7 +112,11 @@ function NativeStackNavigator({
     return <LoggedOut onDismiss={() => setShowLoggedOut(false)} />
   }
   if (onboardingState.isActive) {
-    return <Onboarding />
+    if (NEW_ONBOARDING_ENABLED) {
+      return <NewOnboarding />
+    } else {
+      return <Onboarding />
+    }
   }
   const newDescriptors: typeof descriptors = {}
   for (let key in descriptors) {
diff --git a/web/index.html b/web/index.html
index 92001e716..7dd0e1a72 100644
--- a/web/index.html
+++ b/web/index.html
@@ -40,26 +40,7 @@
         /* Prevent text size change on orientation change https://gist.github.com/tfausak/2222823#file-ios-8-web-app-html-L138 */
         -webkit-text-size-adjust: 100%;
         height: calc(100% + env(safe-area-inset-top));
-        scrollbar-gutter: stable;
-      }
-
-      /* 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;
+        scrollbar-gutter: stable both-edges;
       }
 
       /* Color theming */
@@ -71,7 +52,7 @@
       html.colorMode--dark {
         --text: white;
         --background: hsl(211, 20%, 4%);
-        --backgroundLight: hsl(211, 20%, 10%);
+        --backgroundLight: hsl(211, 20%, 20%);
         color-scheme: dark;
       }
       @media (prefers-color-scheme: light) {
@@ -85,11 +66,33 @@
         html.colorMode--system {
           --text: white;
           --background: hsl(211, 20%, 4%);
-          --backgroundLight: hsl(211, 20%, 10%);
+          --backgroundLight: hsl(211, 20%, 20%);
           color-scheme: dark;
         }
       }
 
+      ::selection {
+        background-color: var(--backgroundLight);
+      }
+
+      /* Remove autofill styles on Webkit */
+      input:autofill,
+      input:-webkit-autofill,
+      input:-webkit-autofill:hover,
+      input:-webkit-autofill:focus,
+      input:-webkit-autofill:active{
+          -webkit-background-clip: text;
+          -webkit-text-fill-color: var(--text);
+          transition: background-color 5000s ease-in-out 0s;
+          box-shadow: inset 0 0 20px 20px var(--background);
+          background: var(--background);
+          color: var(--text);
+      }
+      /* Force left-align date/time inputs on iOS mobile */
+      input::-webkit-date-and-time-value {
+        text-align: left;
+      }
+
       body {
         display: flex;
         /* Allows you to scroll below the viewport; default value is visible */