diff --git a/next-ui/src/components/FormattedMessage.mdx b/next-ui/src/components/FormattedMessage.mdx
new file mode 100644
index 00000000..3c04cf8d
--- /dev/null
+++ b/next-ui/src/components/FormattedMessage.mdx
@@ -0,0 +1,11 @@
+import {Canvas, Meta} from '@storybook/addon-docs/blocks';
+
+import * as Stories from './FormattedMessage.stories';
+
+
+
+# FormattedMessage
+
+Component to display formatted `formatjs` messages using dynamic slots.
+
+
diff --git a/next-ui/src/components/FormattedMessage.stories.ts b/next-ui/src/components/FormattedMessage.stories.ts
new file mode 100644
index 00000000..517a8870
--- /dev/null
+++ b/next-ui/src/components/FormattedMessage.stories.ts
@@ -0,0 +1,73 @@
+import type { Meta, StoryObj } from '@storybook/vue3-vite'
+
+import FormattedMessage from './FormattedMessage.ts'
+
+const meta = {
+ component: FormattedMessage,
+ parameters: {
+ // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
+ },
+ args: {},
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ render: (args: object) => ({
+ components: { FormattedMessage },
+ setup() {
+ return { args }
+ },
+ template: `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ }),
+}
+
+export const NoMarkup: Story = {
+ render: (args: object) => ({
+ components: { FormattedMessage },
+ setup() {
+ return { args }
+ },
+ template: `
+
+ `,
+ }),
+}
+
+export const NoMarkupTag: Story = {
+ render: (args: object) => ({
+ components: { FormattedMessage },
+ setup() {
+ return { args }
+ },
+ template: `
+
+ `,
+ }),
+}
diff --git a/next-ui/src/components/FormattedMessage.ts b/next-ui/src/components/FormattedMessage.ts
new file mode 100644
index 00000000..b3ad2e16
--- /dev/null
+++ b/next-ui/src/components/FormattedMessage.ts
@@ -0,0 +1,26 @@
+import { defineComponent, h } from 'vue'
+import { type MessageDescriptor, useIntl } from 'vue-intl'
+
+// from: https://github.com/formatjs/formatjs/discussions/3961
+export default defineComponent({
+ props: {
+ messageDescriptor: Object as PropType,
+ tag: String,
+ values: Object,
+ },
+ setup(props, context) {
+ const { messageDescriptor, tag, values = {} } = props
+ const intl = useIntl()
+ const slotNames = Object.keys(context.slots)
+
+ const message = intl.formatMessage(messageDescriptor, {
+ ...values,
+ ...slotNames.reduce((slots, name) => {
+ slots[name] = (content) => context.slots[name](() => content)
+ return slots
+ }, {}),
+ })
+
+ return () => (tag || Array.isArray(message) ? h(tag || 'div', message) : message)
+ },
+})