diff --git a/src/components/EmojiPicker/index.tsx b/src/components/EmojiPicker/index.tsx
new file mode 100644
index 0000000000..facc131275
--- /dev/null
+++ b/src/components/EmojiPicker/index.tsx
@@ -0,0 +1,40 @@
+import {type PickerProps, type RootProps, type TriggerProps} from './types'
+
+export * from './types'
+
+/**
+ * Provides emoji picker context and wraps children in a {@link Menu.Root}.
+ *
+ * On emoji select, fires a `textInputWebEmitter` event (for web text inputs
+ * that listen for emoji insertions) and forwards to the optional
+ * `onEmojiSelect` callback.
+ *
+ * @platform web
+ */
+export function Root(_props: RootProps): React.ReactNode {
+ throw new Error('EmojiPopup is not implemented on native')
+}
+
+/**
+ * Passthrough to {@link Menu.Trigger}. Accepts the same render-prop children
+ * pattern.
+ *
+ * @platform web
+ */
+export function Trigger(_props: TriggerProps): React.ReactNode {
+ throw new Error('EmojiPopup is not implemented on native')
+}
+
+/**
+ * Renders the emoji picker inside a Radix `DropdownMenu.Portal`.
+ *
+ * Holding Shift while selecting an emoji keeps the picker open for
+ * multi-select. Otherwise the menu closes after each selection.
+ *
+ * Must be rendered inside a {@link Root}.
+ *
+ * @platform web
+ */
+export function Picker(_props: PickerProps): React.ReactNode {
+ throw new Error('EmojiPopup is not implemented on native')
+}
diff --git a/src/components/EmojiPicker/index.web.tsx b/src/components/EmojiPicker/index.web.tsx
new file mode 100644
index 0000000000..8d5e7788ec
--- /dev/null
+++ b/src/components/EmojiPicker/index.web.tsx
@@ -0,0 +1,150 @@
+import {createContext, useContext, useEffect, useMemo, useRef} from 'react'
+import EmojiPicker from '@emoji-mart/react'
+import {DropdownMenu} from 'radix-ui'
+
+import {useA11y} from '#/state/a11y'
+import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
+import {atoms as a, flatten} from '#/alf'
+import * as Menu from '../Menu'
+import {useWebPreloadEmoji} from './preload'
+import {
+ type Emoji,
+ type PickerProps,
+ type RootProps,
+ type TriggerProps,
+} from './types'
+
+export * from './types'
+
+const EmojiPickerContext = createContext<{
+ onEmojiSelect: (emoji: Emoji) => void
+ nextFocusRef: RootProps['nextFocusRef']
+} | null>(null)
+
+/**
+ * Provides emoji picker context and wraps children in a {@link Menu.Root}.
+ *
+ * On emoji select, fires a `textInputWebEmitter` event (for web text inputs
+ * that listen for emoji insertions) and forwards to the optional
+ * `onEmojiSelect` callback.
+ *
+ * @platform web
+ */
+export function Root({
+ children,
+ control,
+ onEmojiSelect,
+ preloadOnMount = true,
+ nextFocusRef,
+}: RootProps) {
+ useWebPreloadEmoji({immediate: preloadOnMount})
+
+ const value = useMemo(
+ () => ({
+ onEmojiSelect: (emoji: Emoji) => {
+ textInputWebEmitter.emit('emoji-inserted', emoji)
+
+ if (onEmojiSelect) onEmojiSelect(emoji)
+ },
+ nextFocusRef,
+ }),
+ [onEmojiSelect, nextFocusRef],
+ )
+
+ return (
+
+ {children}
+
+ )
+}
+
+/**
+ * Passthrough to {@link Menu.Trigger}. Accepts the same render-prop children
+ * pattern.
+ *
+ * @platform web
+ */
+export function Trigger(props: TriggerProps) {
+ return
+}
+
+/**
+ * Renders the emoji picker inside a Radix `DropdownMenu.Portal`.
+ *
+ * Holding Shift while selecting an emoji keeps the picker open for
+ * multi-select. Otherwise the menu closes after each selection.
+ *
+ * Must be rendered inside a {@link Root}.
+ *
+ * @platform web
+ */
+export function Picker({keepOpenWhenShiftHeld = true}: PickerProps) {
+ const {onEmojiSelect, nextFocusRef} = useEmojiPickerContext()
+ const {control} = Menu.useMenuContext()
+ const {reduceMotionEnabled} = useA11y()
+ const isShiftDown = useRef(false)
+
+ useEffect(() => {
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Shift') {
+ isShiftDown.current = true
+ }
+ }
+ const onKeyUp = (e: KeyboardEvent) => {
+ if (e.key === 'Shift') {
+ isShiftDown.current = false
+ }
+ }
+ window.addEventListener('keydown', onKeyDown, true)
+ window.addEventListener('keyup', onKeyUp, true)
+
+ return () => {
+ window.removeEventListener('keydown', onKeyDown, true)
+ window.removeEventListener('keyup', onKeyUp, true)
+ }
+ }, [])
+
+ return (
+
+ {
+ if (!nextFocusRef) return
+ let element =
+ nextFocusRef instanceof Function
+ ? nextFocusRef()
+ : nextFocusRef.current
+ if (element) {
+ evt.preventDefault()
+ element.focus()
+ }
+ }}>
+
+
+
+ )
+}
+
+function useEmojiPickerContext() {
+ const ctx = useContext(EmojiPickerContext)
+ if (!ctx)
+ throw new Error(
+ 'EmojiPicker.Picker must be used within an EmojiPicker.Root component',
+ )
+ return ctx
+}
diff --git a/src/components/EmojiPicker/preload.ts b/src/components/EmojiPicker/preload.ts
new file mode 100644
index 0000000000..d37216b168
--- /dev/null
+++ b/src/components/EmojiPicker/preload.ts
@@ -0,0 +1,7 @@
+/**
+ * Native no-op. Emoji data preloading is only needed on web where the picker
+ * uses `emoji-mart`.
+ */
+export function useWebPreloadEmoji({}: {immediate?: boolean} = {}) {
+ return () => Promise.resolve()
+}
diff --git a/src/view/com/composer/text-input/web/useWebPreloadEmoji.ts b/src/components/EmojiPicker/preload.web.ts
similarity index 52%
rename from src/view/com/composer/text-input/web/useWebPreloadEmoji.ts
rename to src/components/EmojiPicker/preload.web.ts
index 27636a14b4..4456153b71 100644
--- a/src/view/com/composer/text-input/web/useWebPreloadEmoji.ts
+++ b/src/components/EmojiPicker/preload.web.ts
@@ -7,8 +7,14 @@ import {init} from 'emoji-mart'
let loadRequested = false
/**
- * Preload the emoji picker data to prevent flash.
- * {@link https://github.com/missive/emoji-mart/blob/16978d04a766eec6455e2e8bb21cd8dc0b3c7436/README.md?plain=1#L194}
+ * Preloads emoji-mart data so the picker renders instantly when opened.
+ *
+ * Returns a function that can be called manually to trigger preloading (e.g.
+ * on hover). When `immediate` is `true`, preloading starts on mount.
+ *
+ * Data is only fetched once per page load — subsequent calls are no-ops.
+ *
+ * @see {@link https://github.com/missive/emoji-mart/blob/16978d04a766eec6455e2e8bb21cd8dc0b3c7436/README.md?plain=1#L194 | emoji-mart preloading docs}
*/
export function useWebPreloadEmoji({immediate}: {immediate?: boolean} = {}) {
const preload = useCallback(async () => {
diff --git a/src/components/EmojiPicker/types.ts b/src/components/EmojiPicker/types.ts
new file mode 100644
index 0000000000..4b6300ffda
--- /dev/null
+++ b/src/components/EmojiPicker/types.ts
@@ -0,0 +1,65 @@
+import {type DialogControlProps} from '../Dialog'
+import {type TriggerProps as MenuTriggerProps} from '../Menu/types'
+
+/**
+ * Represents an emoji selected from the picker. Sourced from the `emoji-mart`
+ * library's selection data.
+ */
+export type Emoji = {
+ aliases?: string[]
+ emoticons: string[]
+ id: string
+ keywords: string[]
+ name: string
+ /** The native unicode character for the emoji, e.g. "😀" */
+ native: string
+ shortcodes?: string
+ /** The unicode codepoint, e.g. "1f600" */
+ unified: string
+ /** Skin tone variant (1–6), if applicable */
+ skin?: number
+}
+
+type FocusableElement = {focus: () => void}
+
+export interface RootProps {
+ children: React.ReactNode
+ control?: DialogControlProps
+ /**
+ * Called when the user selects an emoji. On web this fires in addition to
+ * the `textInputWebEmitter` event, so callers that only need the text
+ * insertion can omit this.
+ */
+ onEmojiSelect?: (emoji: Emoji) => void
+ /**
+ * When `true` (default), preloads emoji data as soon as the component
+ * mounts so the picker opens instantly. Set to `false` to defer loading
+ * until the picker is actually opened.
+ */
+ preloadOnMount?: boolean
+ /**
+ * Element to return focus to when the picker closes. Accepts either a ref
+ * or a getter function.
+ */
+ nextFocusRef?:
+ | React.RefObject
+ | (() => FocusableElement | null | undefined)
+}
+
+/**
+ * Props for the trigger button that opens the emoji picker. Extends
+ * {@link MenuTriggerProps} — accepts the same render-prop children pattern.
+ */
+export interface TriggerProps extends MenuTriggerProps {}
+
+/**
+ * Props for the picker panel itself.
+ */
+export interface PickerProps {
+ /**
+ * When `true`, the picker will remain open after selecting an emoji when the Shift key is held down.
+ *
+ * @default true
+ */
+ keepOpenWhenShiftHeld?: boolean
+}
diff --git a/src/components/dms/EmojiReactionPicker.web.tsx b/src/components/dms/EmojiReactionPicker.web.tsx
index 6be85efb4c..1a78a55458 100644
--- a/src/components/dms/EmojiReactionPicker.web.tsx
+++ b/src/components/dms/EmojiReactionPicker.web.tsx
@@ -1,18 +1,14 @@
import {useState} from 'react'
import {Pressable, View} from 'react-native'
import {type ChatBskyConvoDefs} from '@atproto/api'
-import EmojiPicker from '@emoji-mart/react'
-import {msg} from '@lingui/core/macro'
-import {useLingui} from '@lingui/react'
+import {useLingui} from '@lingui/react/macro'
import {DropdownMenu} from 'radix-ui'
import {useSession} from '#/state/session'
-import {type Emoji} from '#/view/com/composer/text-input/web/EmojiPicker'
-import {useWebPreloadEmoji} from '#/view/com/composer/text-input/web/useWebPreloadEmoji'
import {atoms as a, flatten, useTheme} from '#/alf'
+import * as EmojiPicker from '#/components/EmojiPicker'
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid'
import * as Menu from '#/components/Menu'
-import {type TriggerProps} from '#/components/Menu/types'
import {Text} from '#/components/Typography'
import {hasAlreadyReacted, hasReachedReactionLimit} from './util'
@@ -22,19 +18,21 @@ export function EmojiReactionPicker({
onEmojiSelect,
}: {
message: ChatBskyConvoDefs.MessageView
- children?: TriggerProps['children']
+ children?: EmojiPicker.TriggerProps['children']
onEmojiSelect: (emoji: string) => void
}) {
if (!children)
throw new Error('EmojiReactionPicker requires the children prop on web')
- const {_} = useLingui()
+ const {t: l} = useLingui()
return (
-
- {children}
+ onEmojiSelect(emoji.native)}>
+
+ {children}
+
-
+
)
}
@@ -49,8 +47,6 @@ function MenuInner({
const {control} = Menu.useMenuContext()
const {currentAccount} = useSession()
- useWebPreloadEmoji({immediate: true})
-
const [expanded, setExpanded] = useState(false)
const [prevOpen, setPrevOpen] = useState(control.isOpen)
@@ -62,10 +58,6 @@ function MenuInner({
}
}
- const handleEmojiPickerResponse = (emoji: Emoji) => {
- handleEmojiSelect(emoji.native)
- }
-
const handleEmojiSelect = (emoji: string) => {
control.close()
onEmojiSelect(emoji)
@@ -74,18 +66,7 @@ function MenuInner({
const limitReacted = hasReachedReactionLimit(message, currentAccount?.did)
return expanded ? (
-
-
-