diff --git a/packages/compiler-dom/src/parserOptions.ts b/packages/compiler-dom/src/parserOptions.ts
index 2b5e9f01084..244251b9ba9 100644
--- a/packages/compiler-dom/src/parserOptions.ts
+++ b/packages/compiler-dom/src/parserOptions.ts
@@ -1,12 +1,12 @@
 import { ParserOptions, NodeTypes, Namespaces } from '@vue/compiler-core'
-import { isVoidTag, isHTMLTag, isSVGTag } from '@vue/shared'
+import { isVoidTag, isHTMLTag, isSVGTag, isMathMLTag } from '@vue/shared'
 import { TRANSITION, TRANSITION_GROUP } from './runtimeHelpers'
 import { decodeHtmlBrowser } from './decodeHtmlBrowser'
 
 export const parserOptions: ParserOptions = {
   parseMode: 'html',
   isVoidTag,
-  isNativeTag: tag => isHTMLTag(tag) || isSVGTag(tag),
+  isNativeTag: tag => isHTMLTag(tag) || isSVGTag(tag) || isMathMLTag(tag),
   isPreTag: tag => tag === 'pre',
   decodeEntities: __BROWSER__ ? decodeHtmlBrowser : undefined,
 
diff --git a/packages/runtime-core/src/apiCreateApp.ts b/packages/runtime-core/src/apiCreateApp.ts
index 7c0a15b2e98..423c81d7441 100644
--- a/packages/runtime-core/src/apiCreateApp.ts
+++ b/packages/runtime-core/src/apiCreateApp.ts
@@ -16,7 +16,7 @@ import {
   ComponentPublicInstance
 } from './componentPublicInstance'
 import { Directive, validateDirectiveName } from './directives'
-import { RootRenderFunction } from './renderer'
+import { ElementNamespace, RootRenderFunction } from './renderer'
 import { InjectionKey } from './apiInject'
 import { warn } from './warning'
 import { createVNode, cloneVNode, VNode } from './vnode'
@@ -47,7 +47,7 @@ export interface App<HostElement = any> {
   mount(
     rootContainer: HostElement | string,
     isHydrate?: boolean,
-    isSVG?: boolean
+    namespace?: boolean | ElementNamespace
   ): ComponentPublicInstance
   unmount(): void
   provide<T>(key: InjectionKey<T> | string, value: T): this
@@ -297,7 +297,7 @@ export function createAppAPI<HostElement>(
       mount(
         rootContainer: HostElement,
         isHydrate?: boolean,
-        isSVG?: boolean
+        namespace?: boolean | ElementNamespace
       ): any {
         if (!isMounted) {
           // #5571
@@ -313,17 +313,29 @@ export function createAppAPI<HostElement>(
           // this will be set on the root instance on initial mount.
           vnode.appContext = context
 
+          if (namespace === true) {
+            namespace = 'svg'
+          } else if (namespace === false) {
+            namespace = undefined
+          }
+
           // HMR root reload
           if (__DEV__) {
             context.reload = () => {
-              render(cloneVNode(vnode), rootContainer, isSVG)
+              // casting to ElementNamespace because TS doesn't guarantee type narrowing
+              // over function boundaries
+              render(
+                cloneVNode(vnode),
+                rootContainer,
+                namespace as ElementNamespace
+              )
             }
           }
 
           if (isHydrate && hydrate) {
             hydrate(vnode as VNode<Node, Element>, rootContainer as any)
           } else {
-            render(vnode, rootContainer, isSVG)
+            render(vnode, rootContainer, namespace)
           }
           isMounted = true
           app._container = rootContainer
diff --git a/packages/runtime-core/src/compat/global.ts b/packages/runtime-core/src/compat/global.ts
index 2efceb87fa6..0379bb67e8e 100644
--- a/packages/runtime-core/src/compat/global.ts
+++ b/packages/runtime-core/src/compat/global.ts
@@ -17,7 +17,7 @@ import {
 } from '@vue/shared'
 import { warn } from '../warning'
 import { cloneVNode, createVNode } from '../vnode'
-import { RootRenderFunction } from '../renderer'
+import { ElementNamespace, RootRenderFunction } from '../renderer'
 import {
   App,
   AppConfig,
@@ -503,7 +503,13 @@ function installCompatMount(
         container = selectorOrEl || document.createElement('div')
       }
 
-      const isSVG = container instanceof SVGElement
+      let namespace: ElementNamespace
+      if (container instanceof SVGElement) namespace = 'svg'
+      else if (
+        typeof MathMLElement === 'function' &&
+        container instanceof MathMLElement
+      )
+        namespace = 'mathml'
 
       // HMR root reload
       if (__DEV__) {
@@ -511,7 +517,7 @@ function installCompatMount(
           const cloned = cloneVNode(vnode)
           // compat mode will use instance if not reset to null
           cloned.component = null
-          render(cloned, container, isSVG)
+          render(cloned, container, namespace)
         }
       }
 
@@ -538,7 +544,7 @@ function installCompatMount(
       container.innerHTML = ''
 
       // TODO hydration
-      render(vnode, container, isSVG)
+      render(vnode, container, namespace)
 
       if (container instanceof Element) {
         container.removeAttribute('v-cloak')
diff --git a/packages/runtime-core/src/components/KeepAlive.ts b/packages/runtime-core/src/components/KeepAlive.ts
index 8c1b6318887..8f9a94f1109 100644
--- a/packages/runtime-core/src/components/KeepAlive.ts
+++ b/packages/runtime-core/src/components/KeepAlive.ts
@@ -37,7 +37,8 @@ import {
   queuePostRenderEffect,
   MoveType,
   RendererElement,
-  RendererNode
+  RendererNode,
+  ElementNamespace
 } from '../renderer'
 import { setTransitionHooks } from './BaseTransition'
 import { ComponentRenderContext } from '../componentPublicInstance'
@@ -64,7 +65,7 @@ export interface KeepAliveContext extends ComponentRenderContext {
     vnode: VNode,
     container: RendererElement,
     anchor: RendererNode | null,
-    isSVG: boolean,
+    namespace: ElementNamespace,
     optimized: boolean
   ) => void
   deactivate: (vnode: VNode) => void
@@ -125,7 +126,13 @@ const KeepAliveImpl: ComponentOptions = {
     } = sharedContext
     const storageContainer = createElement('div')
 
-    sharedContext.activate = (vnode, container, anchor, isSVG, optimized) => {
+    sharedContext.activate = (
+      vnode,
+      container,
+      anchor,
+      namespace,
+      optimized
+    ) => {
       const instance = vnode.component!
       move(vnode, container, anchor, MoveType.ENTER, parentSuspense)
       // in case props have changed
@@ -136,7 +143,7 @@ const KeepAliveImpl: ComponentOptions = {
         anchor,
         instance,
         parentSuspense,
-        isSVG,
+        namespace,
         vnode.slotScopeIds,
         optimized
       )
diff --git a/packages/runtime-core/src/components/Suspense.ts b/packages/runtime-core/src/components/Suspense.ts
index 5e5521b09be..3668418090b 100644
--- a/packages/runtime-core/src/components/Suspense.ts
+++ b/packages/runtime-core/src/components/Suspense.ts
@@ -18,7 +18,8 @@ import {
   MoveType,
   SetupRenderEffectFn,
   RendererNode,
-  RendererElement
+  RendererElement,
+  ElementNamespace
 } from '../renderer'
 import { queuePostFlushCb } from '../scheduler'
 import { filterSingleRoot, updateHOCHostEl } from '../componentRenderUtils'
@@ -63,7 +64,7 @@ export const SuspenseImpl = {
     anchor: RendererNode | null,
     parentComponent: ComponentInternalInstance | null,
     parentSuspense: SuspenseBoundary | null,
-    isSVG: boolean,
+    namespace: ElementNamespace,
     slotScopeIds: string[] | null,
     optimized: boolean,
     // platform-specific impl passed from renderer
@@ -76,7 +77,7 @@ export const SuspenseImpl = {
         anchor,
         parentComponent,
         parentSuspense,
-        isSVG,
+        namespace,
         slotScopeIds,
         optimized,
         rendererInternals
@@ -88,7 +89,7 @@ export const SuspenseImpl = {
         container,
         anchor,
         parentComponent,
-        isSVG,
+        namespace,
         slotScopeIds,
         optimized,
         rendererInternals
@@ -130,7 +131,7 @@ function mountSuspense(
   anchor: RendererNode | null,
   parentComponent: ComponentInternalInstance | null,
   parentSuspense: SuspenseBoundary | null,
-  isSVG: boolean,
+  namespace: ElementNamespace,
   slotScopeIds: string[] | null,
   optimized: boolean,
   rendererInternals: RendererInternals
@@ -147,7 +148,7 @@ function mountSuspense(
     container,
     hiddenContainer,
     anchor,
-    isSVG,
+    namespace,
     slotScopeIds,
     optimized,
     rendererInternals
@@ -161,7 +162,7 @@ function mountSuspense(
     null,
     parentComponent,
     suspense,
-    isSVG,
+    namespace,
     slotScopeIds
   )
   // now check if we have encountered any async deps
@@ -179,7 +180,7 @@ function mountSuspense(
       anchor,
       parentComponent,
       null, // fallback tree will not have suspense context
-      isSVG,
+      namespace,
       slotScopeIds
     )
     setActiveBranch(suspense, vnode.ssFallback!)
@@ -195,7 +196,7 @@ function patchSuspense(
   container: RendererElement,
   anchor: RendererNode | null,
   parentComponent: ComponentInternalInstance | null,
-  isSVG: boolean,
+  namespace: ElementNamespace,
   slotScopeIds: string[] | null,
   optimized: boolean,
   { p: patch, um: unmount, o: { createElement } }: RendererInternals
@@ -218,7 +219,7 @@ function patchSuspense(
         null,
         parentComponent,
         suspense,
-        isSVG,
+        namespace,
         slotScopeIds,
         optimized
       )
@@ -232,7 +233,7 @@ function patchSuspense(
           anchor,
           parentComponent,
           null, // fallback tree will not have suspense context
-          isSVG,
+          namespace,
           slotScopeIds,
           optimized
         )
@@ -267,7 +268,7 @@ function patchSuspense(
           null,
           parentComponent,
           suspense,
-          isSVG,
+          namespace,
           slotScopeIds,
           optimized
         )
@@ -281,7 +282,7 @@ function patchSuspense(
             anchor,
             parentComponent,
             null, // fallback tree will not have suspense context
-            isSVG,
+            namespace,
             slotScopeIds,
             optimized
           )
@@ -296,7 +297,7 @@ function patchSuspense(
           anchor,
           parentComponent,
           suspense,
-          isSVG,
+          namespace,
           slotScopeIds,
           optimized
         )
@@ -311,7 +312,7 @@ function patchSuspense(
           null,
           parentComponent,
           suspense,
-          isSVG,
+          namespace,
           slotScopeIds,
           optimized
         )
@@ -330,7 +331,7 @@ function patchSuspense(
         anchor,
         parentComponent,
         suspense,
-        isSVG,
+        namespace,
         slotScopeIds,
         optimized
       )
@@ -349,7 +350,7 @@ function patchSuspense(
         null,
         parentComponent,
         suspense,
-        isSVG,
+        namespace,
         slotScopeIds,
         optimized
       )
@@ -376,7 +377,7 @@ export interface SuspenseBoundary {
   vnode: VNode<RendererNode, RendererElement, SuspenseProps>
   parent: SuspenseBoundary | null
   parentComponent: ComponentInternalInstance | null
-  isSVG: boolean
+  namespace: ElementNamespace
   container: RendererElement
   hiddenContainer: RendererElement
   anchor: RendererNode | null
@@ -413,7 +414,7 @@ function createSuspenseBoundary(
   container: RendererElement,
   hiddenContainer: RendererElement,
   anchor: RendererNode | null,
-  isSVG: boolean,
+  namespace: ElementNamespace,
   slotScopeIds: string[] | null,
   optimized: boolean,
   rendererInternals: RendererInternals,
@@ -455,7 +456,7 @@ function createSuspenseBoundary(
     vnode,
     parent: parentSuspense,
     parentComponent,
-    isSVG,
+    namespace,
     container,
     hiddenContainer,
     anchor,
@@ -576,7 +577,7 @@ function createSuspenseBoundary(
         return
       }
 
-      const { vnode, activeBranch, parentComponent, container, isSVG } =
+      const { vnode, activeBranch, parentComponent, container, namespace } =
         suspense
 
       // invoke @fallback event
@@ -594,7 +595,7 @@ function createSuspenseBoundary(
           next(activeBranch!),
           parentComponent,
           null, // fallback tree will not have suspense context
-          isSVG,
+          namespace,
           slotScopeIds,
           optimized
         )
@@ -675,7 +676,7 @@ function createSuspenseBoundary(
             // consider the comment placeholder case.
             hydratedEl ? null : next(instance.subTree),
             suspense,
-            isSVG,
+            namespace,
             optimized
           )
           if (placeholder) {
@@ -721,7 +722,7 @@ function hydrateSuspense(
   vnode: VNode,
   parentComponent: ComponentInternalInstance | null,
   parentSuspense: SuspenseBoundary | null,
-  isSVG: boolean,
+  namespace: ElementNamespace,
   slotScopeIds: string[] | null,
   optimized: boolean,
   rendererInternals: RendererInternals,
@@ -742,7 +743,7 @@ function hydrateSuspense(
     node.parentNode!,
     document.createElement('div'),
     null,
-    isSVG,
+    namespace,
     slotScopeIds,
     optimized,
     rendererInternals,
diff --git a/packages/runtime-core/src/components/Teleport.ts b/packages/runtime-core/src/components/Teleport.ts
index d1327db8ee8..aae7deff336 100644
--- a/packages/runtime-core/src/components/Teleport.ts
+++ b/packages/runtime-core/src/components/Teleport.ts
@@ -6,7 +6,8 @@ import {
   RendererElement,
   RendererNode,
   RendererOptions,
-  traverseStaticChildren
+  traverseStaticChildren,
+  ElementNamespace
 } from '../renderer'
 import { VNode, VNodeArrayChildren, VNodeProps } from '../vnode'
 import { isString, ShapeFlags } from '@vue/shared'
@@ -28,6 +29,9 @@ const isTeleportDisabled = (props: VNode['props']): boolean =>
 const isTargetSVG = (target: RendererElement): boolean =>
   typeof SVGElement !== 'undefined' && target instanceof SVGElement
 
+const isTargetMathML = (target: RendererElement): boolean =>
+  typeof MathMLElement === 'function' && target instanceof MathMLElement
+
 const resolveTarget = <T = RendererElement>(
   props: TeleportProps | null,
   select: RendererOptions['querySelector']
@@ -72,7 +76,7 @@ export const TeleportImpl = {
     anchor: RendererNode | null,
     parentComponent: ComponentInternalInstance | null,
     parentSuspense: SuspenseBoundary | null,
-    isSVG: boolean,
+    namespace: ElementNamespace,
     slotScopeIds: string[] | null,
     optimized: boolean,
     internals: RendererInternals
@@ -109,7 +113,11 @@ export const TeleportImpl = {
       if (target) {
         insert(targetAnchor, target)
         // #2652 we could be teleporting from a non-SVG tree into an SVG tree
-        isSVG = isSVG || isTargetSVG(target)
+        if (namespace === 'svg' || isTargetSVG(target)) {
+          namespace = 'svg'
+        } else if (namespace === 'mathml' || isTargetMathML(target)) {
+          namespace = 'mathml'
+        }
       } else if (__DEV__ && !disabled) {
         warn('Invalid Teleport target on mount:', target, `(${typeof target})`)
       }
@@ -124,7 +132,7 @@ export const TeleportImpl = {
             anchor,
             parentComponent,
             parentSuspense,
-            isSVG,
+            namespace,
             slotScopeIds,
             optimized
           )
@@ -145,7 +153,12 @@ export const TeleportImpl = {
       const wasDisabled = isTeleportDisabled(n1.props)
       const currentContainer = wasDisabled ? container : target
       const currentAnchor = wasDisabled ? mainAnchor : targetAnchor
-      isSVG = isSVG || isTargetSVG(target)
+
+      if (namespace === 'svg' || isTargetSVG(target)) {
+        namespace = 'svg'
+      } else if (namespace === 'mathml' || isTargetMathML(target)) {
+        namespace = 'mathml'
+      }
 
       if (dynamicChildren) {
         // fast path when the teleport happens to be a block root
@@ -155,7 +168,7 @@ export const TeleportImpl = {
           currentContainer,
           parentComponent,
           parentSuspense,
-          isSVG,
+          namespace,
           slotScopeIds
         )
         // even in block tree mode we need to make sure all root-level nodes
@@ -170,7 +183,7 @@ export const TeleportImpl = {
           currentAnchor,
           parentComponent,
           parentSuspense,
-          isSVG,
+          namespace,
           slotScopeIds,
           false
         )
diff --git a/packages/runtime-core/src/hydration.ts b/packages/runtime-core/src/hydration.ts
index d79c09d3d36..156327dd880 100644
--- a/packages/runtime-core/src/hydration.ts
+++ b/packages/runtime-core/src/hydration.ts
@@ -39,7 +39,17 @@ enum DOMNodeTypes {
 let hasMismatch = false
 
 const isSVGContainer = (container: Element) =>
-  /svg/.test(container.namespaceURI!) && container.tagName !== 'foreignObject'
+  container.namespaceURI!.includes('svg') &&
+  container.tagName !== 'foreignObject'
+
+const isMathMLContainer = (container: Element) =>
+  container.namespaceURI!.includes('MathML')
+
+const getContainerType = (container: Element): 'svg' | 'mathml' | undefined => {
+  if (isSVGContainer(container)) return 'svg'
+  if (isMathMLContainer(container)) return 'mathml'
+  return undefined
+}
 
 const isComment = (node: Node): node is Comment =>
   node.nodeType === DOMNodeTypes.COMMENT
@@ -263,7 +273,7 @@ export function createHydrationFunctions(
             null,
             parentComponent,
             parentSuspense,
-            isSVGContainer(container),
+            getContainerType(container),
             optimized
           )
 
@@ -306,7 +316,7 @@ export function createHydrationFunctions(
             vnode,
             parentComponent,
             parentSuspense,
-            isSVGContainer(parentNode(node)!),
+            getContainerType(parentNode(node)!),
             slotScopeIds,
             optimized,
             rendererInternals,
@@ -364,7 +374,7 @@ export function createHydrationFunctions(
                 key,
                 null,
                 props[key],
-                false,
+                undefined,
                 undefined,
                 parentComponent
               )
@@ -378,7 +388,7 @@ export function createHydrationFunctions(
             'onClick',
             null,
             props.onClick,
-            false,
+            undefined,
             undefined,
             parentComponent
           )
@@ -519,7 +529,7 @@ export function createHydrationFunctions(
           null,
           parentComponent,
           parentSuspense,
-          isSVGContainer(container),
+          getContainerType(container),
           slotScopeIds
         )
       }
@@ -611,7 +621,7 @@ export function createHydrationFunctions(
       next,
       parentComponent,
       parentSuspense,
-      isSVGContainer(container),
+      getContainerType(container),
       slotScopeIds
     )
     return next
diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts
index fcc460c43d0..f8d21242b83 100644
--- a/packages/runtime-core/src/index.ts
+++ b/packages/runtime-core/src/index.ts
@@ -260,7 +260,8 @@ export type {
   RendererElement,
   HydrationRenderer,
   RendererOptions,
-  RootRenderFunction
+  RootRenderFunction,
+  ElementNamespace
 } from './renderer'
 export type { RootHydrateFunction } from './hydration'
 export type { Slot, Slots, SlotsType } from './componentSlots'
diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts
index fc762af3d96..52a3aa1404c 100644
--- a/packages/runtime-core/src/renderer.ts
+++ b/packages/runtime-core/src/renderer.ts
@@ -83,10 +83,12 @@ export interface HydrationRenderer extends Renderer<Element | ShadowRoot> {
   hydrate: RootHydrateFunction
 }
 
+export type ElementNamespace = 'svg' | 'mathml' | undefined
+
 export type RootRenderFunction<HostElement = RendererElement> = (
   vnode: VNode | null,
   container: HostElement,
-  isSVG?: boolean
+  namespace?: ElementNamespace
 ) => void
 
 export interface RendererOptions<
@@ -98,7 +100,7 @@ export interface RendererOptions<
     key: string,
     prevValue: any,
     nextValue: any,
-    isSVG?: boolean,
+    namespace?: ElementNamespace,
     prevChildren?: VNode<HostNode, HostElement>[],
     parentComponent?: ComponentInternalInstance | null,
     parentSuspense?: SuspenseBoundary | null,
@@ -108,7 +110,7 @@ export interface RendererOptions<
   remove(el: HostNode): void
   createElement(
     type: string,
-    isSVG?: boolean,
+    namespace?: ElementNamespace,
     isCustomizedBuiltIn?: string,
     vnodeProps?: (VNodeProps & { [key: string]: any }) | null
   ): HostElement
@@ -125,7 +127,7 @@ export interface RendererOptions<
     content: string,
     parent: HostElement,
     anchor: HostNode | null,
-    isSVG: boolean,
+    namespace: ElementNamespace,
     start?: HostNode | null,
     end?: HostNode | null
   ): [HostNode, HostNode]
@@ -170,7 +172,7 @@ type PatchFn = (
   anchor?: RendererNode | null,
   parentComponent?: ComponentInternalInstance | null,
   parentSuspense?: SuspenseBoundary | null,
-  isSVG?: boolean,
+  namespace?: ElementNamespace,
   slotScopeIds?: string[] | null,
   optimized?: boolean
 ) => void
@@ -181,7 +183,7 @@ type MountChildrenFn = (
   anchor: RendererNode | null,
   parentComponent: ComponentInternalInstance | null,
   parentSuspense: SuspenseBoundary | null,
-  isSVG: boolean,
+  namespace: ElementNamespace,
   slotScopeIds: string[] | null,
   optimized: boolean,
   start?: number
@@ -194,7 +196,7 @@ type PatchChildrenFn = (
   anchor: RendererNode | null,
   parentComponent: ComponentInternalInstance | null,
   parentSuspense: SuspenseBoundary | null,
-  isSVG: boolean,
+  namespace: ElementNamespace,
   slotScopeIds: string[] | null,
   optimized: boolean
 ) => void
@@ -205,7 +207,7 @@ type PatchBlockChildrenFn = (
   fallbackContainer: RendererElement,
   parentComponent: ComponentInternalInstance | null,
   parentSuspense: SuspenseBoundary | null,
-  isSVG: boolean,
+  namespace: ElementNamespace,
   slotScopeIds: string[] | null
 ) => void
 
@@ -244,7 +246,7 @@ export type MountComponentFn = (
   anchor: RendererNode | null,
   parentComponent: ComponentInternalInstance | null,
   parentSuspense: SuspenseBoundary | null,
-  isSVG: boolean,
+  namespace: ElementNamespace,
   optimized: boolean
 ) => void
 
@@ -261,7 +263,7 @@ export type SetupRenderEffectFn = (
   container: RendererElement,
   anchor: RendererNode | null,
   parentSuspense: SuspenseBoundary | null,
-  isSVG: boolean,
+  namespace: ElementNamespace,
   optimized: boolean
 ) => void
 
@@ -362,7 +364,7 @@ function baseCreateRenderer(
     anchor = null,
     parentComponent = null,
     parentSuspense = null,
-    isSVG = false,
+    namespace = undefined,
     slotScopeIds = null,
     optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren
   ) => {
@@ -392,9 +394,9 @@ function baseCreateRenderer(
         break
       case Static:
         if (n1 == null) {
-          mountStaticNode(n2, container, anchor, isSVG)
+          mountStaticNode(n2, container, anchor, namespace)
         } else if (__DEV__) {
-          patchStaticNode(n1, n2, container, isSVG)
+          patchStaticNode(n1, n2, container, namespace)
         }
         break
       case Fragment:
@@ -405,7 +407,7 @@ function baseCreateRenderer(
           anchor,
           parentComponent,
           parentSuspense,
-          isSVG,
+          namespace,
           slotScopeIds,
           optimized
         )
@@ -419,7 +421,7 @@ function baseCreateRenderer(
             anchor,
             parentComponent,
             parentSuspense,
-            isSVG,
+            namespace,
             slotScopeIds,
             optimized
           )
@@ -431,7 +433,7 @@ function baseCreateRenderer(
             anchor,
             parentComponent,
             parentSuspense,
-            isSVG,
+            namespace,
             slotScopeIds,
             optimized
           )
@@ -443,7 +445,7 @@ function baseCreateRenderer(
             anchor,
             parentComponent,
             parentSuspense,
-            isSVG,
+            namespace,
             slotScopeIds,
             optimized,
             internals
@@ -456,7 +458,7 @@ function baseCreateRenderer(
             anchor,
             parentComponent,
             parentSuspense,
-            isSVG,
+            namespace,
             slotScopeIds,
             optimized,
             internals
@@ -509,7 +511,7 @@ function baseCreateRenderer(
     n2: VNode,
     container: RendererElement,
     anchor: RendererNode | null,
-    isSVG: boolean
+    namespace: ElementNamespace
   ) => {
     // static nodes are only present when used with compiler-dom/runtime-dom
     // which guarantees presence of hostInsertStaticContent.
@@ -517,7 +519,7 @@ function baseCreateRenderer(
       n2.children as string,
       container,
       anchor,
-      isSVG,
+      namespace,
       n2.el,
       n2.anchor
     )
@@ -530,7 +532,7 @@ function baseCreateRenderer(
     n1: VNode,
     n2: VNode,
     container: RendererElement,
-    isSVG: boolean
+    namespace: ElementNamespace
   ) => {
     // static nodes are only patched during dev for HMR
     if (n2.children !== n1.children) {
@@ -542,7 +544,7 @@ function baseCreateRenderer(
         n2.children as string,
         container,
         anchor,
-        isSVG
+        namespace
       )
     } else {
       n2.el = n1.el
@@ -581,11 +583,16 @@ function baseCreateRenderer(
     anchor: RendererNode | null,
     parentComponent: ComponentInternalInstance | null,
     parentSuspense: SuspenseBoundary | null,
-    isSVG: boolean,
+    namespace: ElementNamespace,
     slotScopeIds: string[] | null,
     optimized: boolean
   ) => {
-    isSVG = isSVG || n2.type === 'svg'
+    if (n2.type === 'svg') {
+      namespace = 'svg'
+    } else if (n2.type === 'math') {
+      namespace = 'mathml'
+    }
+
     if (n1 == null) {
       mountElement(
         n2,
@@ -593,7 +600,7 @@ function baseCreateRenderer(
         anchor,
         parentComponent,
         parentSuspense,
-        isSVG,
+        namespace,
         slotScopeIds,
         optimized
       )
@@ -603,7 +610,7 @@ function baseCreateRenderer(
         n2,
         parentComponent,
         parentSuspense,
-        isSVG,
+        namespace,
         slotScopeIds,
         optimized
       )
@@ -616,17 +623,17 @@ function baseCreateRenderer(
     anchor: RendererNode | null,
     parentComponent: ComponentInternalInstance | null,
     parentSuspense: SuspenseBoundary | null,
-    isSVG: boolean,
+    namespace: ElementNamespace,
     slotScopeIds: string[] | null,
     optimized: boolean
   ) => {
     let el: RendererElement
     let vnodeHook: VNodeHook | undefined | null
-    const { type, props, shapeFlag, transition, dirs } = vnode
+    const { props, shapeFlag, transition, dirs } = vnode
 
     el = vnode.el = hostCreateElement(
       vnode.type as string,
-      isSVG,
+      namespace,
       props && props.is,
       props
     )
@@ -642,7 +649,7 @@ function baseCreateRenderer(
         null,
         parentComponent,
         parentSuspense,
-        isSVG && type !== 'foreignObject',
+        resolveChildrenNamespace(vnode, namespace),
         slotScopeIds,
         optimized
       )
@@ -662,7 +669,7 @@ function baseCreateRenderer(
             key,
             null,
             props[key],
-            isSVG,
+            namespace,
             vnode.children as VNode[],
             parentComponent,
             parentSuspense,
@@ -680,7 +687,7 @@ function baseCreateRenderer(
        * affect non-DOM renderers)
        */
       if ('value' in props) {
-        hostPatchProp(el, 'value', null, props.value)
+        hostPatchProp(el, 'value', null, props.value, namespace)
       }
       if ((vnodeHook = props.onVnodeBeforeMount)) {
         invokeVNodeHook(vnodeHook, parentComponent, vnode)
@@ -764,7 +771,7 @@ function baseCreateRenderer(
     anchor,
     parentComponent,
     parentSuspense,
-    isSVG,
+    namespace: ElementNamespace,
     slotScopeIds,
     optimized,
     start = 0
@@ -780,7 +787,7 @@ function baseCreateRenderer(
         anchor,
         parentComponent,
         parentSuspense,
-        isSVG,
+        namespace,
         slotScopeIds,
         optimized
       )
@@ -792,7 +799,7 @@ function baseCreateRenderer(
     n2: VNode,
     parentComponent: ComponentInternalInstance | null,
     parentSuspense: SuspenseBoundary | null,
-    isSVG: boolean,
+    namespace: ElementNamespace,
     slotScopeIds: string[] | null,
     optimized: boolean
   ) => {
@@ -822,7 +829,6 @@ function baseCreateRenderer(
       dynamicChildren = null
     }
 
-    const areChildrenSVG = isSVG && n2.type !== 'foreignObject'
     if (dynamicChildren) {
       patchBlockChildren(
         n1.dynamicChildren!,
@@ -830,7 +836,7 @@ function baseCreateRenderer(
         el,
         parentComponent,
         parentSuspense,
-        areChildrenSVG,
+        resolveChildrenNamespace(n2, namespace),
         slotScopeIds
       )
       if (__DEV__) {
@@ -846,7 +852,7 @@ function baseCreateRenderer(
         null,
         parentComponent,
         parentSuspense,
-        areChildrenSVG,
+        resolveChildrenNamespace(n2, namespace),
         slotScopeIds,
         false
       )
@@ -866,21 +872,21 @@ function baseCreateRenderer(
           newProps,
           parentComponent,
           parentSuspense,
-          isSVG
+          namespace
         )
       } else {
         // class
         // this flag is matched when the element has dynamic class bindings.
         if (patchFlag & PatchFlags.CLASS) {
           if (oldProps.class !== newProps.class) {
-            hostPatchProp(el, 'class', null, newProps.class, isSVG)
+            hostPatchProp(el, 'class', null, newProps.class, namespace)
           }
         }
 
         // style
         // this flag is matched when the element has dynamic style bindings
         if (patchFlag & PatchFlags.STYLE) {
-          hostPatchProp(el, 'style', oldProps.style, newProps.style, isSVG)
+          hostPatchProp(el, 'style', oldProps.style, newProps.style, namespace)
         }
 
         // props
@@ -903,7 +909,7 @@ function baseCreateRenderer(
                 key,
                 prev,
                 next,
-                isSVG,
+                namespace,
                 n1.children as VNode[],
                 parentComponent,
                 parentSuspense,
@@ -930,7 +936,7 @@ function baseCreateRenderer(
         newProps,
         parentComponent,
         parentSuspense,
-        isSVG
+        namespace
       )
     }
 
@@ -949,7 +955,7 @@ function baseCreateRenderer(
     fallbackContainer,
     parentComponent,
     parentSuspense,
-    isSVG,
+    namespace: ElementNamespace,
     slotScopeIds
   ) => {
     for (let i = 0; i < newChildren.length; i++) {
@@ -979,7 +985,7 @@ function baseCreateRenderer(
         null,
         parentComponent,
         parentSuspense,
-        isSVG,
+        namespace,
         slotScopeIds,
         true
       )
@@ -993,7 +999,7 @@ function baseCreateRenderer(
     newProps: Data,
     parentComponent: ComponentInternalInstance | null,
     parentSuspense: SuspenseBoundary | null,
-    isSVG: boolean
+    namespace: ElementNamespace
   ) => {
     if (oldProps !== newProps) {
       if (oldProps !== EMPTY_OBJ) {
@@ -1004,7 +1010,7 @@ function baseCreateRenderer(
               key,
               oldProps[key],
               null,
-              isSVG,
+              namespace,
               vnode.children as VNode[],
               parentComponent,
               parentSuspense,
@@ -1025,7 +1031,7 @@ function baseCreateRenderer(
             key,
             prev,
             next,
-            isSVG,
+            namespace,
             vnode.children as VNode[],
             parentComponent,
             parentSuspense,
@@ -1034,7 +1040,7 @@ function baseCreateRenderer(
         }
       }
       if ('value' in newProps) {
-        hostPatchProp(el, 'value', oldProps.value, newProps.value)
+        hostPatchProp(el, 'value', oldProps.value, newProps.value, namespace)
       }
     }
   }
@@ -1046,7 +1052,7 @@ function baseCreateRenderer(
     anchor: RendererNode | null,
     parentComponent: ComponentInternalInstance | null,
     parentSuspense: SuspenseBoundary | null,
-    isSVG: boolean,
+    namespace: ElementNamespace,
     slotScopeIds: string[] | null,
     optimized: boolean
   ) => {
@@ -1085,7 +1091,7 @@ function baseCreateRenderer(
         fragmentEndAnchor,
         parentComponent,
         parentSuspense,
-        isSVG,
+        namespace,
         slotScopeIds,
         optimized
       )
@@ -1106,7 +1112,7 @@ function baseCreateRenderer(
           container,
           parentComponent,
           parentSuspense,
-          isSVG,
+          namespace,
           slotScopeIds
         )
         if (__DEV__) {
@@ -1134,7 +1140,7 @@ function baseCreateRenderer(
           fragmentEndAnchor,
           parentComponent,
           parentSuspense,
-          isSVG,
+          namespace,
           slotScopeIds,
           optimized
         )
@@ -1149,7 +1155,7 @@ function baseCreateRenderer(
     anchor: RendererNode | null,
     parentComponent: ComponentInternalInstance | null,
     parentSuspense: SuspenseBoundary | null,
-    isSVG: boolean,
+    namespace: ElementNamespace,
     slotScopeIds: string[] | null,
     optimized: boolean
   ) => {
@@ -1160,7 +1166,7 @@ function baseCreateRenderer(
           n2,
           container,
           anchor,
-          isSVG,
+          namespace,
           optimized
         )
       } else {
@@ -1170,7 +1176,7 @@ function baseCreateRenderer(
           anchor,
           parentComponent,
           parentSuspense,
-          isSVG,
+          namespace,
           optimized
         )
       }
@@ -1185,7 +1191,7 @@ function baseCreateRenderer(
     anchor,
     parentComponent,
     parentSuspense,
-    isSVG,
+    namespace: ElementNamespace,
     optimized
   ) => {
     // 2.x compat may pre-create the component instance before actually
@@ -1245,7 +1251,7 @@ function baseCreateRenderer(
       container,
       anchor,
       parentSuspense,
-      isSVG,
+      namespace,
       optimized
     )
 
@@ -1296,7 +1302,7 @@ function baseCreateRenderer(
     container,
     anchor,
     parentSuspense,
-    isSVG,
+    namespace: ElementNamespace,
     optimized
   ) => {
     const componentUpdateFn = () => {
@@ -1380,7 +1386,7 @@ function baseCreateRenderer(
             anchor,
             instance,
             parentSuspense,
-            isSVG
+            namespace
           )
           if (__DEV__) {
             endMeasure(instance, `patch`)
@@ -1499,7 +1505,7 @@ function baseCreateRenderer(
           getNextHostNode(prevTree),
           instance,
           parentSuspense,
-          isSVG
+          namespace
         )
         if (__DEV__) {
           endMeasure(instance, `patch`)
@@ -1599,7 +1605,7 @@ function baseCreateRenderer(
     anchor,
     parentComponent,
     parentSuspense,
-    isSVG,
+    namespace: ElementNamespace,
     slotScopeIds,
     optimized = false
   ) => {
@@ -1620,7 +1626,7 @@ function baseCreateRenderer(
           anchor,
           parentComponent,
           parentSuspense,
-          isSVG,
+          namespace,
           slotScopeIds,
           optimized
         )
@@ -1634,7 +1640,7 @@ function baseCreateRenderer(
           anchor,
           parentComponent,
           parentSuspense,
-          isSVG,
+          namespace,
           slotScopeIds,
           optimized
         )
@@ -1663,7 +1669,7 @@ function baseCreateRenderer(
             anchor,
             parentComponent,
             parentSuspense,
-            isSVG,
+            namespace,
             slotScopeIds,
             optimized
           )
@@ -1685,7 +1691,7 @@ function baseCreateRenderer(
             anchor,
             parentComponent,
             parentSuspense,
-            isSVG,
+            namespace,
             slotScopeIds,
             optimized
           )
@@ -1701,7 +1707,7 @@ function baseCreateRenderer(
     anchor: RendererNode | null,
     parentComponent: ComponentInternalInstance | null,
     parentSuspense: SuspenseBoundary | null,
-    isSVG: boolean,
+    namespace: ElementNamespace,
     slotScopeIds: string[] | null,
     optimized: boolean
   ) => {
@@ -1722,7 +1728,7 @@ function baseCreateRenderer(
         null,
         parentComponent,
         parentSuspense,
-        isSVG,
+        namespace,
         slotScopeIds,
         optimized
       )
@@ -1745,7 +1751,7 @@ function baseCreateRenderer(
         anchor,
         parentComponent,
         parentSuspense,
-        isSVG,
+        namespace,
         slotScopeIds,
         optimized,
         commonLength
@@ -1761,7 +1767,7 @@ function baseCreateRenderer(
     parentAnchor: RendererNode | null,
     parentComponent: ComponentInternalInstance | null,
     parentSuspense: SuspenseBoundary | null,
-    isSVG: boolean,
+    namespace: ElementNamespace,
     slotScopeIds: string[] | null,
     optimized: boolean
   ) => {
@@ -1786,7 +1792,7 @@ function baseCreateRenderer(
           null,
           parentComponent,
           parentSuspense,
-          isSVG,
+          namespace,
           slotScopeIds,
           optimized
         )
@@ -1812,7 +1818,7 @@ function baseCreateRenderer(
           null,
           parentComponent,
           parentSuspense,
-          isSVG,
+          namespace,
           slotScopeIds,
           optimized
         )
@@ -1844,7 +1850,7 @@ function baseCreateRenderer(
             anchor,
             parentComponent,
             parentSuspense,
-            isSVG,
+            namespace,
             slotScopeIds,
             optimized
           )
@@ -1947,7 +1953,7 @@ function baseCreateRenderer(
             null,
             parentComponent,
             parentSuspense,
-            isSVG,
+            namespace,
             slotScopeIds,
             optimized
           )
@@ -1976,7 +1982,7 @@ function baseCreateRenderer(
             anchor,
             parentComponent,
             parentSuspense,
-            isSVG,
+            namespace,
             slotScopeIds,
             optimized
           )
@@ -2321,13 +2327,21 @@ function baseCreateRenderer(
     return hostNextSibling((vnode.anchor || vnode.el)!)
   }
 
-  const render: RootRenderFunction = (vnode, container, isSVG) => {
+  const render: RootRenderFunction = (vnode, container, namespace) => {
     if (vnode == null) {
       if (container._vnode) {
         unmount(container._vnode, null, null, true)
       }
     } else {
-      patch(container._vnode || null, vnode, container, null, null, null, isSVG)
+      patch(
+        container._vnode || null,
+        vnode,
+        container,
+        null,
+        null,
+        null,
+        namespace
+      )
     }
     flushPreFlushCbs()
     flushPostFlushCbs()
@@ -2362,6 +2376,20 @@ function baseCreateRenderer(
   }
 }
 
+function resolveChildrenNamespace(
+  { type, props }: VNode,
+  currentNamespace: ElementNamespace
+): ElementNamespace {
+  return (currentNamespace === 'svg' && type === 'foreignObject') ||
+    (currentNamespace === 'mathml' &&
+      type === 'annotation-xml' &&
+      props &&
+      props.encoding &&
+      props.encoding.includes('html'))
+    ? undefined
+    : currentNamespace
+}
+
 function toggleRecurse(
   { effect, update }: ComponentInternalInstance,
   allowed: boolean
diff --git a/packages/runtime-dom/__tests__/nodeOps.spec.ts b/packages/runtime-dom/__tests__/nodeOps.spec.ts
index 2573974c955..87adafd6696 100644
--- a/packages/runtime-dom/__tests__/nodeOps.spec.ts
+++ b/packages/runtime-dom/__tests__/nodeOps.spec.ts
@@ -2,7 +2,7 @@ import { nodeOps, svgNS } from '../src/nodeOps'
 
 describe('runtime-dom: node-ops', () => {
   test("the <select>'s multiple attr should be set in createElement", () => {
-    const el = nodeOps.createElement('select', false, undefined, {
+    const el = nodeOps.createElement('select', undefined, undefined, {
       multiple: ''
     }) as HTMLSelectElement
     const option1 = nodeOps.createElement('option') as HTMLOptionElement
@@ -21,7 +21,12 @@ describe('runtime-dom: node-ops', () => {
     test('fresh insertion', () => {
       const content = `<div>one</div><div>two</div>three`
       const parent = document.createElement('div')
-      const nodes = nodeOps.insertStaticContent!(content, parent, null, false)
+      const nodes = nodeOps.insertStaticContent!(
+        content,
+        parent,
+        null,
+        undefined
+      )
       expect(parent.innerHTML).toBe(content)
       expect(nodes[0]).toBe(parent.firstChild)
       expect(nodes[1]).toBe(parent.lastChild)
@@ -33,7 +38,12 @@ describe('runtime-dom: node-ops', () => {
       const parent = document.createElement('div')
       parent.innerHTML = existing
       const anchor = parent.firstChild
-      const nodes = nodeOps.insertStaticContent!(content, parent, anchor, false)
+      const nodes = nodeOps.insertStaticContent!(
+        content,
+        parent,
+        anchor,
+        undefined
+      )
       expect(parent.innerHTML).toBe(content + existing)
       expect(nodes[0]).toBe(parent.firstChild)
       expect(nodes[1]).toBe(parent.childNodes[parent.childNodes.length - 2])
@@ -46,7 +56,7 @@ describe('runtime-dom: node-ops', () => {
         content,
         parent,
         null,
-        true
+        'svg'
       )
       expect(parent.innerHTML).toBe(content)
       expect(first).toBe(parent.firstChild)
@@ -65,7 +75,7 @@ describe('runtime-dom: node-ops', () => {
         content,
         parent,
         anchor,
-        true
+        'svg'
       )
       expect(parent.innerHTML).toBe(content + existing)
       expect(first).toBe(parent.firstChild)
@@ -88,7 +98,7 @@ describe('runtime-dom: node-ops', () => {
         content,
         parent,
         anchor,
-        false,
+        undefined,
         cached.firstChild,
         cached.lastChild
       )
diff --git a/packages/runtime-dom/__tests__/patchAttrs.spec.ts b/packages/runtime-dom/__tests__/patchAttrs.spec.ts
index b78dd44c634..f06fb4a5ea6 100644
--- a/packages/runtime-dom/__tests__/patchAttrs.spec.ts
+++ b/packages/runtime-dom/__tests__/patchAttrs.spec.ts
@@ -4,15 +4,15 @@ import { xlinkNS } from '../src/modules/attrs'
 describe('runtime-dom: attrs patching', () => {
   test('xlink attributes', () => {
     const el = document.createElementNS('http://www.w3.org/2000/svg', 'use')
-    patchProp(el, 'xlink:href', null, 'a', true)
+    patchProp(el, 'xlink:href', null, 'a', 'svg')
     expect(el.getAttributeNS(xlinkNS, 'href')).toBe('a')
-    patchProp(el, 'xlink:href', 'a', null, true)
+    patchProp(el, 'xlink:href', 'a', null, 'svg')
     expect(el.getAttributeNS(xlinkNS, 'href')).toBe(null)
   })
 
   test('textContent attributes /w svg', () => {
     const el = document.createElementNS('http://www.w3.org/2000/svg', 'use')
-    patchProp(el, 'textContent', null, 'foo', true)
+    patchProp(el, 'textContent', null, 'foo', 'svg')
     expect(el.attributes.length).toBe(0)
     expect(el.innerHTML).toBe('foo')
   })
diff --git a/packages/runtime-dom/__tests__/patchClass.spec.ts b/packages/runtime-dom/__tests__/patchClass.spec.ts
index c8da741677a..7af4c885cbe 100644
--- a/packages/runtime-dom/__tests__/patchClass.spec.ts
+++ b/packages/runtime-dom/__tests__/patchClass.spec.ts
@@ -25,7 +25,7 @@ describe('runtime-dom: class patching', () => {
 
   test('svg', () => {
     const el = document.createElementNS(svgNS, 'svg')
-    patchProp(el, 'class', null, 'foo', true)
+    patchProp(el, 'class', null, 'foo', 'svg')
     expect(el.getAttribute('class')).toBe('foo')
   })
 })
diff --git a/packages/runtime-dom/src/index.ts b/packages/runtime-dom/src/index.ts
index ad818a34bb7..124789100e2 100644
--- a/packages/runtime-dom/src/index.ts
+++ b/packages/runtime-dom/src/index.ts
@@ -10,7 +10,8 @@ import {
   RootHydrateFunction,
   isRuntimeOnly,
   DeprecationTypes,
-  compatUtils
+  compatUtils,
+  ElementNamespace
 } from '@vue/runtime-core'
 import { nodeOps } from './nodeOps'
 import { patchProp } from './patchProp'
@@ -21,7 +22,8 @@ import {
   isHTMLTag,
   isSVGTag,
   extend,
-  NOOP
+  NOOP,
+  isMathMLTag
 } from '@vue/shared'
 
 declare module '@vue/reactivity' {
@@ -99,7 +101,7 @@ export const createApp = ((...args) => {
 
     // clear content before mounting
     container.innerHTML = ''
-    const proxy = mount(container, false, container instanceof SVGElement)
+    const proxy = mount(container, false, resolveRootNamespace(container))
     if (container instanceof Element) {
       container.removeAttribute('v-cloak')
       container.setAttribute('data-v-app', '')
@@ -122,18 +124,30 @@ export const createSSRApp = ((...args) => {
   app.mount = (containerOrSelector: Element | ShadowRoot | string): any => {
     const container = normalizeContainer(containerOrSelector)
     if (container) {
-      return mount(container, true, container instanceof SVGElement)
+      return mount(container, true, resolveRootNamespace(container))
     }
   }
 
   return app
 }) as CreateAppFunction<Element>
 
+function resolveRootNamespace(container: Element): ElementNamespace {
+  if (container instanceof SVGElement) {
+    return 'svg'
+  }
+  if (
+    typeof MathMLElement === 'function' &&
+    container instanceof MathMLElement
+  ) {
+    return 'mathml'
+  }
+}
+
 function injectNativeTagCheck(app: App) {
   // Inject `isNativeTag`
   // this is used for component name validation (dev only)
   Object.defineProperty(app.config, 'isNativeTag', {
-    value: (tag: string) => isHTMLTag(tag) || isSVGTag(tag),
+    value: (tag: string) => isHTMLTag(tag) || isSVGTag(tag) || isMathMLTag(tag),
     writable: false
   })
 }
diff --git a/packages/runtime-dom/src/nodeOps.ts b/packages/runtime-dom/src/nodeOps.ts
index daf56fc6f3a..b9595536d00 100644
--- a/packages/runtime-dom/src/nodeOps.ts
+++ b/packages/runtime-dom/src/nodeOps.ts
@@ -1,6 +1,7 @@
 import { RendererOptions } from '@vue/runtime-core'
 
 export const svgNS = 'http://www.w3.org/2000/svg'
+export const mathmlNS = 'http://www.w3.org/1998/Math/MathML'
 
 const doc = (typeof document !== 'undefined' ? document : null) as Document
 
@@ -18,10 +19,13 @@ export const nodeOps: Omit<RendererOptions<Node, Element>, 'patchProp'> = {
     }
   },
 
-  createElement: (tag, isSVG, is, props): Element => {
-    const el = isSVG
-      ? doc.createElementNS(svgNS, tag)
-      : doc.createElement(tag, is ? { is } : undefined)
+  createElement: (tag, namespace, is, props): Element => {
+    const el =
+      namespace === 'svg'
+        ? doc.createElementNS(svgNS, tag)
+        : namespace === 'mathml'
+          ? doc.createElementNS(mathmlNS, tag)
+          : doc.createElement(tag, is ? { is } : undefined)
 
     if (tag === 'select' && props && props.multiple != null) {
       ;(el as HTMLSelectElement).setAttribute('multiple', props.multiple)
@@ -56,7 +60,7 @@ export const nodeOps: Omit<RendererOptions<Node, Element>, 'patchProp'> = {
   // Reason: innerHTML.
   // Static content here can only come from compiled templates.
   // As long as the user only uses trusted templates, this is safe.
-  insertStaticContent(content, parent, anchor, isSVG, start, end) {
+  insertStaticContent(content, parent, anchor, namespace, start, end) {
     // <parent> before | first ... last | anchor </parent>
     const before = anchor ? anchor.previousSibling : parent.lastChild
     // #5308 can only take cached path if:
@@ -70,10 +74,16 @@ export const nodeOps: Omit<RendererOptions<Node, Element>, 'patchProp'> = {
       }
     } else {
       // fresh insert
-      templateContainer.innerHTML = isSVG ? `<svg>${content}</svg>` : content
+      templateContainer.innerHTML =
+        namespace === 'svg'
+          ? `<svg>${content}</svg>`
+          : namespace === 'mathml'
+            ? `<math>${content}</math>`
+            : content
+
       const template = templateContainer.content
-      if (isSVG) {
-        // remove outer svg wrapper
+      if (namespace === 'svg' || namespace === 'mathml') {
+        // remove outer svg/math wrapper
         const wrapper = template.firstChild!
         while (wrapper.firstChild) {
           template.appendChild(wrapper.firstChild)
diff --git a/packages/runtime-dom/src/patchProp.ts b/packages/runtime-dom/src/patchProp.ts
index e5883d18fca..d0f6923c5b4 100644
--- a/packages/runtime-dom/src/patchProp.ts
+++ b/packages/runtime-dom/src/patchProp.ts
@@ -20,12 +20,13 @@ export const patchProp: DOMRendererOptions['patchProp'] = (
   key,
   prevValue,
   nextValue,
-  isSVG = false,
+  namespace,
   prevChildren,
   parentComponent,
   parentSuspense,
   unmountChildren
 ) => {
+  const isSVG = namespace === 'svg'
   if (key === 'class') {
     patchClass(el, nextValue, isSVG)
   } else if (key === 'style') {
diff --git a/packages/shared/src/domTagConfig.ts b/packages/shared/src/domTagConfig.ts
index 535aa6be718..4cbb02b5f79 100644
--- a/packages/shared/src/domTagConfig.ts
+++ b/packages/shared/src/domTagConfig.ts
@@ -27,6 +27,13 @@ const SVG_TAGS =
   'polygon,polyline,radialGradient,rect,set,solidcolor,stop,switch,symbol,' +
   'text,textPath,title,tspan,unknown,use,view'
 
+// https://developer.mozilla.org/en-US/docs/Web/MathML/Element
+const MATH_TAGS =
+  'math,maction,annotation,annotation-xml,menclose,merror,mfenced,mfrac,mi,' +
+  'mmultiscripts,mn,mo,mover,mpadded,mphantom,mprescripts,mroot,mrow,ms,' +
+  'semantics,mspace,msqrt,mstyle,msub,msup,msubsup,mtable,mtd,mtext,mtr,' +
+  'munder,munderover'
+
 const VOID_TAGS =
   'area,base,br,col,embed,hr,img,input,link,meta,param,source,track,wbr'
 
@@ -40,6 +47,11 @@ export const isHTMLTag = /*#__PURE__*/ makeMap(HTML_TAGS)
  * Do NOT use in runtime code paths unless behind `__DEV__` flag.
  */
 export const isSVGTag = /*#__PURE__*/ makeMap(SVG_TAGS)
+/**
+ * Compiler only.
+ * Do NOT use in runtime code paths unless behind `__DEV__` flag.
+ */
+export const isMathMLTag = /*#__PURE__*/ makeMap(MATH_TAGS)
 /**
  * Compiler only.
  * Do NOT use in runtime code paths unless behind `__DEV__` flag.
diff --git a/packages/vue/__tests__/mathmlNamespace.spec.ts b/packages/vue/__tests__/mathmlNamespace.spec.ts
new file mode 100644
index 00000000000..3fe1221f534
--- /dev/null
+++ b/packages/vue/__tests__/mathmlNamespace.spec.ts
@@ -0,0 +1,80 @@
+// MathML logic is technically dom-specific, but the logic is placed in core
+// because splitting it out of core would lead to unnecessary complexity in both
+// the renderer and compiler implementations.
+// Related files:
+// - runtime-core/src/renderer.ts
+// - compiler-core/src/transforms/transformElement.ts
+
+import { vtcKey } from '../../runtime-dom/src/components/Transition'
+import { render, h, ref, nextTick } from '../src'
+
+describe('MathML support', () => {
+  afterEach(() => {
+    document.body.innerHTML = ''
+  })
+
+  test('should mount elements with correct html namespace', () => {
+    const root = document.createElement('div')
+    document.body.appendChild(root)
+    const App = {
+      template: `
+      <math display="block" id="e0">
+        <semantics id="e1">
+          <mrow id="e2">
+            <msup>
+              <mi>x</mi>
+              <mn>2</mn>
+            </msup>
+            <mo>+</mo>
+            <mi>y</mi>
+          </mrow>
+
+          <annotation-xml encoding="text/html" id="e3">
+            <div id="e4" />
+            <svg id="e5" />
+          </annotation-xml>
+        </semantics>
+      </math>
+      `
+    }
+    render(h(App), root)
+    const e0 = document.getElementById('e0')!
+    expect(e0.namespaceURI).toMatch('Math')
+    expect(e0.querySelector('#e1')!.namespaceURI).toMatch('Math')
+    expect(e0.querySelector('#e2')!.namespaceURI).toMatch('Math')
+    expect(e0.querySelector('#e3')!.namespaceURI).toMatch('Math')
+    expect(e0.querySelector('#e4')!.namespaceURI).toMatch('xhtml')
+    expect(e0.querySelector('#e5')!.namespaceURI).toMatch('svg')
+  })
+
+  test('should patch elements with correct namespaces', async () => {
+    const root = document.createElement('div')
+    document.body.appendChild(root)
+    const cls = ref('foo')
+    const App = {
+      setup: () => ({ cls }),
+      template: `
+        <div>
+          <math id="f1" :class="cls">
+            <annotation encoding="text/html">
+              <div id="f2" :class="cls"/>
+            </annotation>
+          </math>
+        </div>
+      `
+    }
+    render(h(App), root)
+    const f1 = document.querySelector('#f1')!
+    const f2 = document.querySelector('#f2')!
+    expect(f1.getAttribute('class')).toBe('foo')
+    expect(f2.className).toBe('foo')
+
+    // set a transition class on the <div> - which is only respected on non-svg
+    // patches
+    ;(f2 as any)[vtcKey] = ['baz']
+    cls.value = 'bar'
+    await nextTick()
+    expect(f1.getAttribute('class')).toBe('bar')
+    expect(f2.className).toBe('bar baz')
+  })
+})
diff --git a/packages/vue/__tests__/svgNamespace.spec.ts b/packages/vue/__tests__/svgNamespace.spec.ts
index e944e7d8663..df52bbf60a8 100644
--- a/packages/vue/__tests__/svgNamespace.spec.ts
+++ b/packages/vue/__tests__/svgNamespace.spec.ts
@@ -9,7 +9,11 @@ import { vtcKey } from '../../runtime-dom/src/components/Transition'
 import { render, h, ref, nextTick } from '../src'
 
 describe('SVG support', () => {
-  test('should mount elements with correct namespaces', () => {
+  afterEach(() => {
+    document.body.innerHTML = ''
+  })
+
+  test('should mount elements with correct html namespace', () => {
     const root = document.createElement('div')
     document.body.appendChild(root)
     const App = {
@@ -18,6 +22,8 @@ describe('SVG support', () => {
           <svg id="e1">
             <foreignObject id="e2">
               <div id="e3"/>
+              <svg id="e4"/>
+              <math id="e5"/>
             </foreignObject>
           </svg>
         </div>
@@ -29,6 +35,8 @@ describe('SVG support', () => {
     expect(e0.querySelector('#e1')!.namespaceURI).toMatch('svg')
     expect(e0.querySelector('#e2')!.namespaceURI).toMatch('svg')
     expect(e0.querySelector('#e3')!.namespaceURI).toMatch('xhtml')
+    expect(e0.querySelector('#e4')!.namespaceURI).toMatch('svg')
+    expect(e0.querySelector('#e5')!.namespaceURI).toMatch('Math')
   })
 
   test('should patch elements with correct namespaces', async () => {
diff --git a/scripts/setupVitest.ts b/scripts/setupVitest.ts
index cd1e672fd28..41df611a267 100644
--- a/scripts/setupVitest.ts
+++ b/scripts/setupVitest.ts
@@ -1,5 +1,7 @@
 import { type SpyInstance } from 'vitest'
 
+vi.stubGlobal('MathMLElement', class MathMLElement {})
+
 expect.extend({
   toHaveBeenWarned(received: string) {
     asserted.add(received)