diff --git a/components/publish/PublishEmojiPicker.client.vue b/components/publish/PublishEmojiPicker.client.vue
new file mode 100644
index 00000000..3365dd2b
--- /dev/null
+++ b/components/publish/PublishEmojiPicker.client.vue
@@ -0,0 +1,46 @@
+<script setup lang="ts">
+import type { Picker } from 'emoji-mart'
+
+const emit = defineEmits<{
+  (e: 'select', code: string): void
+}>()
+
+const el = $ref<HTMLElement>()
+let picker = $ref<Picker>()
+
+async function openEmojiPicker() {
+  if (!picker) {
+    const { Picker } = await import('emoji-mart')
+    picker = new Picker({
+      data: () => import('@emoji-mart/data').then(r => r.default),
+      onEmojiSelect(e: any) {
+        emit('select', e.native)
+      },
+      theme: isDark.value ? 'dark' : 'light',
+    })
+    // TODO: custom picker
+    el?.appendChild(picker as any as HTMLElement)
+  }
+}
+
+watchEffect(() => {
+  if (!picker)
+    return
+  picker.update({
+    theme: isDark.value ? 'dark' : 'light',
+  })
+})
+</script>
+
+<template>
+  <VDropdown
+    @apply-show="openEmojiPicker()"
+  >
+    <button btn-action-icon :title="$t('tooltip.emoji')">
+      <div i-ri:emotion-line />
+    </button>
+    <template #popper>
+      <div ref="el" />
+    </template>
+  </VDropdown>
+</template>
diff --git a/components/publish/PublishWidget.vue b/components/publish/PublishWidget.vue
index 49993cda..c0ae1449 100644
--- a/components/publish/PublishWidget.vue
+++ b/components/publish/PublishWidget.vue
@@ -64,6 +64,10 @@ async function handlePaste(evt: ClipboardEvent) {
   await uploadAttachments(Array.from(files))
 }
 
+function insertText(text: string) {
+  editor.value?.chain().insertContent(text).focus().run()
+}
+
 async function pickAttachments() {
   const files = await fileOpen([
     {
@@ -232,6 +236,8 @@ defineExpose({
         v-if="shouldExpanded" flex="~ gap-2 1" m="l--1" pt-2 justify="between" max-full
         border="t base"
       >
+        <PublishEmojiPicker @select="insertText" />
+
         <CommonTooltip placement="bottom" :content="$t('tooltip.add_media')">
           <button btn-action-icon :aria-label="$t('tooltip.add_media')" @click="pickAttachments">
             <div i-ri:image-add-line />
diff --git a/locales/en-US.json b/locales/en-US.json
index 20beb98a..90ffbbf6 100644
--- a/locales/en-US.json
+++ b/locales/en-US.json
@@ -300,6 +300,7 @@
     "add_content_warning": "Add content warning",
     "add_media": "Add images, a video or an audio file",
     "change_content_visibility": "Change content visibility",
+    "emoji": "Emoji",
     "explore_links_intro": "These news stories are being talked about by people on this and other servers of the decentralized network right now.",
     "explore_posts_intro": "These posts from this and other servers in the decentralized network are gaining traction on this server right now.",
     "explore_tags_intro": "These hashtags are gaining traction among people on this and other servers of the decentralized network right now.",
diff --git a/package.json b/package.json
index 4c1e0607..16eb6a66 100644
--- a/package.json
+++ b/package.json
@@ -57,6 +57,7 @@
   "devDependencies": {
     "@antfu/eslint-config": "^0.34.0",
     "@antfu/ni": "^0.18.8",
+    "@emoji-mart/data": "^1.1.0",
     "@iconify-json/carbon": "^1.1.11",
     "@iconify-json/logos": "^1.1.19",
     "@iconify-json/material-symbols": "^1.1.25",
@@ -74,6 +75,7 @@
     "@vitejs/plugin-vue": "^3.2.0",
     "@vue-macros/nuxt": "^0.1.2",
     "@vueuse/nuxt": "^9.8.2",
+    "emoji-mart": "^5.4.0",
     "eslint": "^8.29.0",
     "esno": "^0.16.3",
     "fs-extra": "^11.1.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index aa0fc4fe..a0a1da9e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3,6 +3,7 @@ lockfileVersion: 5.4
 specifiers:
   '@antfu/eslint-config': ^0.34.0
   '@antfu/ni': ^0.18.8
+  '@emoji-mart/data': ^1.1.0
   '@fnando/sparkline': ^0.3.10
   '@iconify-json/carbon': ^1.1.11
   '@iconify-json/logos': ^1.1.19
@@ -34,6 +35,7 @@ specifiers:
   '@vueuse/nuxt': ^9.8.2
   blurhash: ^2.0.4
   browser-fs-access: ^0.31.1
+  emoji-mart: ^5.4.0
   eslint: ^8.29.0
   esno: ^0.16.3
   floating-vue: 2.0.0-beta.20
@@ -105,6 +107,7 @@ dependencies:
 devDependencies:
   '@antfu/eslint-config': 0.34.0_s5ps7njkmjlaqajutnox5ntcla
   '@antfu/ni': 0.18.8
+  '@emoji-mart/data': 1.1.0
   '@iconify-json/carbon': 1.1.11
   '@iconify-json/logos': 1.1.19
   '@iconify-json/material-symbols': 1.1.25
@@ -122,6 +125,7 @@ devDependencies:
   '@vitejs/plugin-vue': 3.2.0
   '@vue-macros/nuxt': 0.1.2_bvgxowbvgmi7uddrvyjqbdegoy
   '@vueuse/nuxt': 9.8.2_nuxt@3.0.0
+  emoji-mart: 5.4.0
   eslint: 8.29.0
   esno: 0.16.3
   fs-extra: 11.1.0
@@ -1459,6 +1463,10 @@ packages:
       mime: 3.0.0
     dev: true
 
+  /@emoji-mart/data/1.1.0:
+    resolution: {integrity: sha512-gwwGC0v5+BQM5On8hy0Uw7qT+xBHVLFuamHj8wHLo4JkuYM+XlGbQuQZj/X7JJLQuBiHs4d3Xh2O+h6YlbtCCA==}
+    dev: true
+
   /@esbuild-kit/cjs-loader/2.4.1:
     resolution: {integrity: sha512-lhc/XLith28QdW0HpHZvZKkorWgmCNT7sVelMHDj3HFdTfdqkwEKvT+aXVQtNAmCC39VJhunDkWhONWB7335mg==}
     dependencies:
@@ -1624,8 +1632,8 @@ packages:
       vue-i18n:
         optional: true
     dependencies:
-      '@intlify/message-compiler': 9.3.0-beta.10
-      '@intlify/shared': 9.3.0-beta.10
+      '@intlify/message-compiler': 9.3.0-beta.11
+      '@intlify/shared': 9.3.0-beta.11
       jsonc-eslint-parser: 1.4.1
       source-map: 0.6.1
       vue-i18n: 9.3.0-beta.10
@@ -1657,11 +1665,24 @@ packages:
       source-map: 0.6.1
     dev: true
 
+  /@intlify/message-compiler/9.3.0-beta.11:
+    resolution: {integrity: sha512-gGGfBGzM7JBXp1Q9gbDAy5jELz9ho3ILqnpxp2yp64+gkqohrqc2YXIvCdwZoc6AtKIh/Zmv4sWVqxkvMsBWtQ==}
+    engines: {node: '>= 14'}
+    dependencies:
+      '@intlify/shared': 9.3.0-beta.11
+      source-map: 0.6.1
+    dev: true
+
   /@intlify/shared/9.3.0-beta.10:
     resolution: {integrity: sha512-h93uAanbAt/XgjDHclrVB7xix6r7Uz11wx0iGNOCdHP7aA2LCJjUT3uNbekJjjbo+Fl5jzTSJZdm2SexzoqhRA==}
     engines: {node: '>= 14'}
     dev: true
 
+  /@intlify/shared/9.3.0-beta.11:
+    resolution: {integrity: sha512-CtbotesxTRiC3bRyXyv1NG39fkqJ790f8z8xFaeIXSZpOdiyxoh5BIyypCzSFQZDGLwz0Q9gyWbW1XpxQJm68Q==}
+    engines: {node: '>= 14'}
+    dev: true
+
   /@intlify/unplugin-vue-i18n/0.8.0_vue-i18n@9.3.0-beta.10:
     resolution: {integrity: sha512-bqMDYrbmV0oMLGHTdYMUXfcEsy2rPwQnGrQAg4gvw5FimvJfTQt3RliLVayT5ldOfeT2g0IUc/0t7LPeGrFUag==}
     engines: {node: '>= 14.16'}
@@ -1678,7 +1699,7 @@ packages:
         optional: true
     dependencies:
       '@intlify/bundle-utils': 3.4.0_vue-i18n@9.3.0-beta.10
-      '@intlify/shared': 9.3.0-beta.10
+      '@intlify/shared': 9.3.0-beta.11
       '@rollup/pluginutils': 4.2.1
       '@vue/compiler-sfc': 3.2.45
       debug: 4.3.4
@@ -4725,6 +4746,10 @@ packages:
     resolution: {integrity: sha512-M8WEXFuKXMYMVr45fo8mq0wUrrJHheiKZf6BArTKk9ZBYCKJEOU5H8cdWgDT+qCVZf7Na4lVUaZsA+h6uA9+PA==}
     dev: true
 
+  /emoji-mart/5.4.0:
+    resolution: {integrity: sha512-xrRrUmMqZG64oRxmUZcf8zSMUGQtIUYUL3aZD5iMkqAve+I9wMNh3OVOXL7NW9fEm48L2LI3BUPpj/DUIAJrVg==}
+    dev: true
+
   /emoji-regex/8.0.0:
     resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
     dev: true
@@ -9966,7 +9991,7 @@ packages:
       vue-router:
         optional: true
     dependencies:
-      '@intlify/shared': 9.3.0-beta.10
+      '@intlify/shared': 9.3.0-beta.11
       '@intlify/vue-i18n-bridge': 0.8.0_vue-i18n@9.3.0-beta.10
       '@intlify/vue-router-bridge': 0.8.0
       ufo: 1.0.1
diff --git a/styles/global.css b/styles/global.css
index aaba9cc4..b7416316 100644
--- a/styles/global.css
+++ b/styles/global.css
@@ -175,3 +175,7 @@ body {
   stroke: var(--c-primary);
   stroke-width: 2;
 }
+
+em-emoji-picker {
+  --border-radius: 0;
+}