diff --git a/demo/html_demo.py b/demo/html_demo.py
new file mode 100644
index 000000000..c3c2ae082
--- /dev/null
+++ b/demo/html_demo.py
@@ -0,0 +1,11 @@
+import mesop as me
+
+
+@me.page(path="/html_demo")
+def app():
+  me.html(
+    """
+Custom HTML
+<a href="https://google.github.io/mesop/" target="_blank">mesop</a>
+"""
+  )
diff --git a/demo/main.py b/demo/main.py
index 91240b6a1..d73db1d27 100644
--- a/demo/main.py
+++ b/demo/main.py
@@ -30,6 +30,7 @@
 import code_demo as code_demo  # cannot call it code due to python library naming conflict
 import divider as divider
 import embed as embed
+import html_demo as html_demo
 import icon as icon
 import image as image
 import input as input
@@ -146,6 +147,7 @@ class Section:
     name="Advanced",
     examples=[
       Example(name="embed"),
+      Example(name="html_demo"),
       Example(name="plot"),
     ],
   ),
diff --git a/docs/components/html.md b/docs/components/html.md
new file mode 100644
index 000000000..6d70f8b42
--- /dev/null
+++ b/docs/components/html.md
@@ -0,0 +1,17 @@
+## Overview
+
+The HTML component allows you to add custom HTML to your Mesop app.
+
+> Note: the HTML is [sanitized by Angular](https://angular.dev/best-practices/security#sanitization-example) for web security reasons so potentially unsafe code like JavaScript is removed.
+
+## Examples
+
+<iframe class="component-demo" src="https://mesop-y677hytkra-uc.a.run.app/html"></iframe>
+
+```python
+--8<-- "demo/html_demo.py"
+```
+
+## API
+
+::: mesop.components.html.html.html
diff --git a/mesop/BUILD b/mesop/BUILD
index 0c3b9d1b3..360346e9b 100644
--- a/mesop/BUILD
+++ b/mesop/BUILD
@@ -19,6 +19,7 @@ py_library(
     deps = [
         ":version",
         # REF(//scripts/scaffold_component.py):insert_component_import
+        "//mesop/components/html:py",
         "//mesop/components/uploader:py",
         "//mesop/components/code:py",
         "//mesop/components/embed:py",
diff --git a/mesop/__init__.py b/mesop/__init__.py
index 0ad5e6594..fbff595f3 100644
--- a/mesop/__init__.py
+++ b/mesop/__init__.py
@@ -56,6 +56,7 @@
 from mesop.components.code.code import code as code
 from mesop.components.divider.divider import divider as divider
 from mesop.components.embed.embed import embed as embed
+from mesop.components.html.html import html as html
 from mesop.components.icon.icon import icon as icon
 from mesop.components.image.image import image as image
 from mesop.components.input.input import EnterEvent as EnterEvent
diff --git a/mesop/components/html/BUILD b/mesop/components/html/BUILD
new file mode 100644
index 000000000..a028007ab
--- /dev/null
+++ b/mesop/components/html/BUILD
@@ -0,0 +1,12 @@
+load("//mesop/components:defs.bzl", "mesop_component")
+
+package(
+    default_visibility = ["//build_defs:mesop_internal"],
+)
+
+mesop_component(
+    name = "html",
+    ng_deps = [
+        "//mesop/web/src/safe_iframe",
+    ],
+)
diff --git a/mesop/components/html/__init__.py b/mesop/components/html/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/mesop/components/html/e2e/BUILD b/mesop/components/html/e2e/BUILD
new file mode 100644
index 000000000..f77d6e0f5
--- /dev/null
+++ b/mesop/components/html/e2e/BUILD
@@ -0,0 +1,13 @@
+load("//build_defs:defaults.bzl", "py_library")
+
+package(
+    default_visibility = ["//build_defs:mesop_examples"],
+)
+
+py_library(
+    name = "e2e",
+    srcs = glob(["*.py"]),
+    deps = [
+        "//mesop",
+    ],
+)
diff --git a/mesop/components/html/e2e/__init__.py b/mesop/components/html/e2e/__init__.py
new file mode 100644
index 000000000..18b37d7d7
--- /dev/null
+++ b/mesop/components/html/e2e/__init__.py
@@ -0,0 +1 @@
+from . import html_app as html_app
diff --git a/mesop/components/html/e2e/html_app.py b/mesop/components/html/e2e/html_app.py
new file mode 100644
index 000000000..c9b4f95af
--- /dev/null
+++ b/mesop/components/html/e2e/html_app.py
@@ -0,0 +1,11 @@
+import mesop as me
+
+
+@me.page(path="/components/html/e2e/html_app")
+def app():
+  me.html(
+    """
+Custom HTML
+<a href="https://google.github.io/mesop/" target="_blank">mesoplink</a>
+"""
+  )
diff --git a/mesop/components/html/e2e/html_test.ts b/mesop/components/html/e2e/html_test.ts
new file mode 100644
index 000000000..2be4c8524
--- /dev/null
+++ b/mesop/components/html/e2e/html_test.ts
@@ -0,0 +1,9 @@
+import {test, expect} from '@playwright/test';
+
+test('test', async ({page}) => {
+  await page.goto('/components/html/e2e/html_app');
+  // mesop is the HTML link so we're checking that it's rendered.
+  expect(await page.getByText('Custom HTML').textContent()).toContain(
+    'mesoplink',
+  );
+});
diff --git a/mesop/components/html/html.ng.html b/mesop/components/html/html.ng.html
new file mode 100644
index 000000000..28d4b24ac
--- /dev/null
+++ b/mesop/components/html/html.ng.html
@@ -0,0 +1 @@
+<div [innerHTML]="config().getHtml()" [style]="getStyle()"></div>
diff --git a/mesop/components/html/html.proto b/mesop/components/html/html.proto
new file mode 100644
index 000000000..fd707c19b
--- /dev/null
+++ b/mesop/components/html/html.proto
@@ -0,0 +1,7 @@
+syntax = "proto2";
+
+package mesop.components.html;
+
+message HtmlType {
+    optional string html = 1;
+}
diff --git a/mesop/components/html/html.py b/mesop/components/html/html.py
new file mode 100644
index 000000000..e59411cd1
--- /dev/null
+++ b/mesop/components/html/html.py
@@ -0,0 +1,41 @@
+import mesop.components.html.html_pb2 as html_pb
+from mesop.component_helpers import (
+  Border,
+  BorderSide,
+  Style,
+  insert_component,
+  register_native_component,
+)
+
+
+@register_native_component
+def html(
+  html: str = "",
+  *,
+  style: Style | None = None,
+  key: str | None = None,
+):
+  """
+  This function renders custom HTML inside an iframe for web security isolation.
+
+  Args:
+      html: The HTML content to be rendered.
+      style: The style to apply to the embed, such as width and height.
+      key: The component [key](../guides/components.md#component-key).
+  """
+  if style is None:
+    style = Style()
+  if style.border is None:
+    style.border = Border.all(
+      BorderSide(
+        width=0,
+      )
+    )
+  insert_component(
+    key=key,
+    type_name="html",
+    proto=html_pb.HtmlType(
+      html=html,
+    ),
+    style=style,
+  )
diff --git a/mesop/components/html/html.ts b/mesop/components/html/html.ts
new file mode 100644
index 000000000..662515840
--- /dev/null
+++ b/mesop/components/html/html.ts
@@ -0,0 +1,51 @@
+import {Component, ElementRef, Input, ViewChild} from '@angular/core';
+import {
+  Key,
+  Style,
+  Type,
+} from 'mesop/mesop/protos/ui_jspb_proto_pb/mesop/protos/ui_pb';
+import {HtmlType} from 'mesop/mesop/components/html/html_jspb_proto_pb/mesop/components/html/html_pb';
+import {formatStyle} from '../../web/src/utils/styles';
+
+@Component({
+  selector: 'mesop-html',
+  templateUrl: 'html.ng.html',
+  standalone: true,
+})
+export class HtmlComponent {
+  @Input({required: true}) type!: Type;
+  @Input() key!: Key;
+  @Input() style!: Style;
+  @ViewChild('iframe', {read: ElementRef}) iframe!: ElementRef;
+  private _config!: HtmlType;
+  private srcDoc!: string;
+
+  ngOnChanges() {
+    this._config = HtmlType.deserializeBinary(
+      this.type.getValue() as unknown as Uint8Array,
+    );
+    // const previousSrcDoc = this.srcDoc;
+    // this.srcDoc = this._config.getHtml()!;
+
+    // // Reload iframe if the URL has changed.
+    // if (
+    //   this.srcDoc !== previousSrcDoc &&
+    //   this.iframe &&
+    //   this.iframe.nativeElement
+    // ) {
+    //   this.loadIframe();
+    // }
+  }
+
+  ngAfterViewInit() {
+    // this.loadIframe();
+  }
+
+  config(): HtmlType {
+    return this._config;
+  }
+
+  getStyle(): string {
+    return formatStyle(this.style);
+  }
+}
diff --git a/mesop/example_index.py b/mesop/example_index.py
index f75a1f6de..8e237d3cf 100644
--- a/mesop/example_index.py
+++ b/mesop/example_index.py
@@ -28,4 +28,5 @@
 import mesop.components.table.e2e as table_e2e
 import mesop.components.embed.e2e as embed_e2e
 import mesop.components.uploader.e2e as uploader_e2e
+import mesop.components.html.e2e as html_e2e
 # REF(//scripts/scaffold_component.py):insert_component_e2e_import_export
diff --git a/mesop/examples/BUILD b/mesop/examples/BUILD
index 1d3fd24ba..89474abfc 100644
--- a/mesop/examples/BUILD
+++ b/mesop/examples/BUILD
@@ -15,6 +15,7 @@ py_library(
     deps = [
         "//demo",
         # REF(//scripts/scaffold_component.py):insert_component_e2e_import
+        "//mesop/components/html/e2e",
         "//mesop/components/uploader/e2e",
         "//mesop/components/embed/e2e",
         "//mesop/components/table/e2e",
diff --git a/mesop/web/src/component_renderer/BUILD b/mesop/web/src/component_renderer/BUILD
index 12c197e7a..20288825f 100644
--- a/mesop/web/src/component_renderer/BUILD
+++ b/mesop/web/src/component_renderer/BUILD
@@ -14,6 +14,7 @@ ng_module(
     ]) + ["component_renderer.css"],
     deps = [
         # REF(//scripts/scaffold_component.py):insert_component_import
+        "//mesop/components/html:ng",
         "//mesop/components/uploader:ng",
         "//mesop/components/embed:ng",
         "//mesop/components/table:ng",
diff --git a/mesop/web/src/component_renderer/type_to_component.ts b/mesop/web/src/component_renderer/type_to_component.ts
index a758d03c5..0b2dbe763 100644
--- a/mesop/web/src/component_renderer/type_to_component.ts
+++ b/mesop/web/src/component_renderer/type_to_component.ts
@@ -1,3 +1,4 @@
+import {HtmlComponent} from '../../../components/html/html';
 import {UploaderComponent} from '../../../components/uploader/uploader';
 import {EmbedComponent} from '../../../components/embed/embed';
 import {TableComponent} from '../../../components/table/table';
@@ -53,6 +54,7 @@ export class UserDefinedComponent implements BaseComponent {
 }
 
 export const typeToComponent = {
+  'html': HtmlComponent,
   'uploader': UploaderComponent,
   'embed': EmbedComponent,
   'table': TableComponent,
diff --git a/mesop/web/src/safe_iframe/safe_iframe.ts b/mesop/web/src/safe_iframe/safe_iframe.ts
index 38098338f..e202b642f 100644
--- a/mesop/web/src/safe_iframe/safe_iframe.ts
+++ b/mesop/web/src/safe_iframe/safe_iframe.ts
@@ -11,6 +11,12 @@ export function setIframeSrc(iframe: HTMLIFrameElement, src: string) {
   setIframeSrcImpl(iframe, src);
 }
 
+export function setIframeSrcDoc(iframe: HTMLIFrameElement, srcDoc: string) {
+  // Intentionally delegate to an impl function because the following
+  // line will be modified downstream.
+  setIframeSrcDocImpl(iframe, srcDoc);
+}
+
 // copybara:strip_begin(external-only)
 function setIframeSrcImpl(iframe: HTMLIFrameElement, src: string) {
   // This is a tightly controlled list of attributes that enables us to
@@ -30,4 +36,24 @@ function setIframeSrcImpl(iframe: HTMLIFrameElement, src: string) {
 
   iframe.src = sanitizeJavaScriptUrl(src)!;
 }
+
+function setIframeSrcDocImpl(iframe: HTMLIFrameElement, srcdoc: string) {
+  // This is a tightly controlled list of attributes that enables us to
+  // secure sandbox iframes. Do not add additional attributes without
+  // consulting a security resource.
+  //
+  // Ref:
+  // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#sandbox
+  iframe.sandbox.add(
+    'allow-same-origin',
+    'allow-scripts',
+    'allow-forms',
+    'allow-popups',
+    'allow-popups-to-escape-sandbox',
+    'allow-storage-access-by-user-activation',
+  );
+
+  // Check if there's any santiziation that's needed.
+  iframe.srcdoc = srcdoc;
+}
 // copybara:strip_end
diff --git a/mkdocs.yml b/mkdocs.yml
index f8cd5bd54..362044324 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -51,6 +51,7 @@ nav:
           - Tooltip: components/tooltip.md
       - Advanced:
           - Embed: components/embed.md
+          - HTML: components/html.md
           - Plot: components/plot.md
   - API:
       - Page: api/page.md