diff --git a/web/src/ui/emojipicker/EmojiGroup.tsx b/web/src/ui/emojipicker/EmojiGroup.tsx
new file mode 100644
index 0000000..dc39da0
--- /dev/null
+++ b/web/src/ui/emojipicker/EmojiGroup.tsx
@@ -0,0 +1,114 @@
+// gomuks - A Matrix client written in Go.
+// Copyright (C) 2024 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+import React, { use, useCallback } from "react"
+import { stringToRoomStateGUID } from "@/api/types"
+import useContentVisibility from "@/util/contentvisibility.ts"
+import { CATEGORY_FREQUENTLY_USED, CustomEmojiPack, Emoji, PartialEmoji, categories } from "@/util/emoji"
+import useEvent from "@/util/useEvent.ts"
+import { ClientContext } from "../ClientContext.ts"
+import renderEmoji from "./renderEmoji.tsx"
+
+interface EmojiGroupProps {
+ emojis: Emoji[]
+ categoryID: number | string
+ selected?: string[]
+ pack?: CustomEmojiPack
+ isWatched?: boolean
+ onSelect: (emoji?: PartialEmoji) => void
+ setPreviewEmoji: (emoji?: Emoji) => void
+}
+
+export const EmojiGroup = ({
+ emojis,
+ categoryID,
+ selected,
+ pack,
+ isWatched,
+ onSelect,
+ setPreviewEmoji,
+}: EmojiGroupProps) => {
+ const client = use(ClientContext)!
+ const [isVisible, divRef] = useContentVisibility(true)
+
+ const getEmojiFromAttrs = (elem: HTMLButtonElement) => {
+ const idx = elem.getAttribute("data-emoji-index")
+ if (!idx) {
+ return
+ }
+ const emoji = emojis[+idx]
+ if (!emoji) {
+ return
+ }
+ return emoji
+ }
+ const onClickEmoji = useEvent((evt: React.MouseEvent) =>
+ onSelect(getEmojiFromAttrs(evt.currentTarget)))
+ const onMouseOverEmoji = useEvent((evt: React.MouseEvent) =>
+ setPreviewEmoji(getEmojiFromAttrs(evt.currentTarget)))
+ const onMouseOutEmoji = useCallback(() => setPreviewEmoji(undefined), [setPreviewEmoji])
+ const onClickSubscribePack = useEvent((evt: React.MouseEvent) => {
+ const guid = stringToRoomStateGUID(evt.currentTarget.getAttribute("data-pack-id"))
+ if (!guid) {
+ return
+ }
+ client.subscribeToEmojiPack(guid, true)
+ .catch(err => window.alert(`Failed to subscribe to emoji pack: ${err}`))
+ })
+ const onClickUnsubscribePack = useEvent((evt: React.MouseEvent) => {
+ const guid = stringToRoomStateGUID(evt.currentTarget.getAttribute("data-pack-id"))
+ if (!guid) {
+ return
+ }
+ client.subscribeToEmojiPack(guid, false)
+ .catch(err => window.alert(`Failed to unsubscribe from emoji pack: ${err}`))
+ })
+
+ let categoryName: string
+ if (typeof categoryID === "number") {
+ categoryName = categories[categoryID]
+ } else if (categoryID === CATEGORY_FREQUENTLY_USED) {
+ categoryName = CATEGORY_FREQUENTLY_USED
+ } else if (pack) {
+ categoryName = pack.name
+ } else {
+ categoryName = "Unknown category"
+ }
+ return