diff --git a/src/assets/spawn_message-hover.png b/src/assets/spawn_message-hover.png
new file mode 100644
index 0000000000000000000000000000000000000000..ce99dd993b8f655ab0c1552726681dd40537b557
Binary files /dev/null and b/src/assets/spawn_message-hover.png differ
diff --git a/src/assets/spawn_message.png b/src/assets/spawn_message.png
new file mode 100644
index 0000000000000000000000000000000000000000..8f6ce1e2064e6ec10e00cc64b99d0660e4ed1ee3
Binary files /dev/null and b/src/assets/spawn_message.png differ
diff --git a/src/assets/stylesheets/presence-log.scss b/src/assets/stylesheets/presence-log.scss
index bed6d5cd50cae9b07dd406009c01917ac48477d8..aefc553c9e9a959081ade436eb7b5383306e521c 100644
--- a/src/assets/stylesheets/presence-log.scss
+++ b/src/assets/stylesheets/presence-log.scss
@@ -25,7 +25,36 @@
     margin: 8px 64px 8px 16px;
     font-size: 0.8em;
     padding: 8px 16px;
-    border-radius: 16px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+
+    :local(.message-body) {
+      margin-left: 4px;
+      white-space: pre;
+    }
+
+    :local(.message-body-multi) {
+      margin-left: 0px;
+    }
+
+    :local(.message-body-mono) {
+      font-family: monospace;
+      font-size: 14px;
+    }
+
+    :local(.message-wrap) {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+    }
+
+    :local(.message-wrap-multi) {
+      display: flex;
+      align-items: flex-start;
+      justify-content: center;
+      flex-direction: column;
+    }
 
     a {
       color: $action-color;
@@ -35,6 +64,29 @@
       max-width: 75%;
     }
 
+    :local(.spawn-message) {
+      appearance: none;
+      -moz-appearance: none;
+      -webkit-appearance: none;
+      outline-style: none;
+      width: 24px;
+      height: 24px;
+      background-size: 100%;
+      border: 0;
+      display: flex;
+      justify-content: center;
+      align-items: center;
+      align-self: flex-start;
+      cursor: pointer;
+      background-image: url(../spawn_message.png);
+      margin-right: 6px;
+      background-color: transparent;
+    }
+
+    :local(.spawn-message):hover {
+      background-image: url(../spawn_message-hover.png);
+    }
+
     &:local(.media) {
       display: flex;
       align-items: center;
@@ -62,26 +114,66 @@
     transition: visibility 0s 0.5s, opacity 0.5s linear, transform 0.5s;
   }
 
+  :local(.presence-log-entry-with-button) {
+    padding: 8px 18px 8px 10px;
+  }
 }
 
 :local(.presence-log-in-room) {
-  max-height: 200px;
-
-  @media(min-height: 800px) and (min-width: 600px) {
-    max-height: 400px;
-  }
-
   position: absolute;
   bottom: 165px;
 
   :local(.presence-log-entry) {
     background-color: $hud-panel-background;
     color: $light-text;
+    min-height: 18px;
 
     user-select: none;
     -moz-user-select: none;
     -webkit-user-select: none;
     -ms-user-select: none;
+
+    a {
+      color: white;
+    }
+  }
+}
+
+:local(.presence-log-spawn) {
+  position: absolute;
+  top: 0;
+  z-index: -10;
+  width: auto;
+  margin: 0;
+
+  :local(.presence-log-entry) {
+    background-color: white;
+    color: black;
+    min-height: 18px;
+    padding: 8px 16px;
+    border-radius: 16px;
+    line-height: 18px;
+    margin: 0;
+
+    :local(.message-body) {
+      margin-left: 0;
+    }
+
+    a {
+      color: white;
+    }
+  }
+
+  :local(.presence-log-entry-one-line) {
+    font-weight: bold;
+    line-height: 19px;
+    text-align: center;
+  }
+
+  :local(.presence-log-emoji) {
+    background-color: transparent;
+    padding: 0;
+    margin: 0;
   }
 }
 
diff --git a/src/assets/stylesheets/ui-root.scss b/src/assets/stylesheets/ui-root.scss
index 11de7f546b713ca297e99e92e090bb355e55e334..b7761c48d938ea6e19504ef940bf84ad3a718c37 100644
--- a/src/assets/stylesheets/ui-root.scss
+++ b/src/assets/stylesheets/ui-root.scss
@@ -204,10 +204,12 @@
   -moz-appearance: none;
   -webkit-appearance: none;
   outline-style: none;
+  overflow: hidden;
+  resize: none;
   background-color: transparent;
   color: black;
   padding: 8px 1.25em;
-  line-height: 2em;
+  line-height: 28px;
   font-size: 1.1em;
   width: 100%;
   border: 0px;
@@ -224,9 +226,11 @@
 :local(.message-entry-submit) {
   @extend %action-button;
   position: absolute;
-  right: 12px;
+  right: 10px;
   height: 32px;
   min-width: 80px;
+  bottom: 8px;
+  border-radius: 10px;
 }
 
 :local(.message-entry-in-room) {
@@ -246,11 +250,29 @@
   border-radius: 16px;
   pointer-events: auto;
   opacity: 0.3;
-  transition: opacity 0.25s linear;
+  transition: opacity 0.15s linear;
 
   :local(.message-entry-input-in-room) {
     color: white;
     padding: 8px 1.25em;
+    margin-left: 32px;
+  }
+
+  :local(.message-entry-spawn) {
+    @extend %action-button;
+    position: absolute;
+    left: 12px;
+    height: 32px;
+    width: 32px;
+    bottom: 8px;
+    min-width: auto;
+    background-size: 90%;
+    background-image: url(../spawn_message.png);
+    background-position-x: 1px;
+    background-position-y: 1px;
+    padding: 0;
+    border-radius: 16px;
+    visibility: hidden;
   }
 
   :local(.message-entry-submit-in-room) {
@@ -259,11 +281,15 @@
   }
 }
 
-:local(.message-entry-in-room):hover {
+:local(.message-entry-in-room):focus-within {
   opacity: 1.0;
-  transition: opacity 0.25s linear;
+  transition: opacity 0.15s linear;
 
   :local(.message-entry-submit-in-room) {
     visibility: visible;
   }
+
+  :local(.message-entry-spawn) {
+    visibility: visible;
+  }
 }
diff --git a/src/hub.html b/src/hub.html
index 99232684d4d2da80d13a3e953ffad076f3eeb748..fc27508c6827eca9eb4e72b2638cd75e8ec5bb61 100644
--- a/src/hub.html
+++ b/src/hub.html
@@ -32,6 +32,7 @@
         vr-mode-ui="enabled: false"
         stats-plus="false"
         action-to-event__mute="path: /actions/muteMic; event: action_mute;"
+        action-to-event__focus_chat="path: /actions/focusChat; event: action_focus_chat;"
     >
 
         <a-assets>
diff --git a/src/hub.js b/src/hub.js
index 93221603b2ed89a4ef85e698d4eee5c842766cd1..f16044fa877f9afc61ee9c4b4b5a7aedd321a5fa 100644
--- a/src/hub.js
+++ b/src/hub.js
@@ -316,6 +316,7 @@ document.addEventListener("DOMContentLoaded", async () => {
   window.APP.scene = scene;
 
   registerNetworkSchemas();
+
   remountUI({
     hubChannel,
     linkChannel,
@@ -325,6 +326,8 @@ document.addEventListener("DOMContentLoaded", async () => {
     initialIsSubscribed: subscriptions.isSubscribed()
   });
 
+  scene.addEventListener("action_focus_chat", () => document.querySelector(".chat-focus-target").focus());
+
   pollForSupportAvailability(isSupportAvailable => remountUI({ isSupportAvailable }));
 
   const platformUnsupportedReason = getPlatformUnsupportedReason();
@@ -501,8 +504,9 @@ document.addEventListener("DOMContentLoaded", async () => {
   hubPhxChannel.on("message", ({ session_id, type, body }) => {
     const userInfo = hubPhxPresence.state[session_id];
     if (!userInfo) return;
+    const maySpawn = scene.is("entered");
 
-    addToPresenceLog({ name: userInfo.metas[0].profile.displayName, type, body });
+    addToPresenceLog({ name: userInfo.metas[0].profile.displayName, type, body, maySpawn });
   });
 
   linkChannel.setSocket(socket);
diff --git a/src/react-components/chat-message.js b/src/react-components/chat-message.js
new file mode 100644
index 0000000000000000000000000000000000000000..992f311f571298b4a2794c803ad56c28a70b42b6
--- /dev/null
+++ b/src/react-components/chat-message.js
@@ -0,0 +1,113 @@
+import React from "react";
+import ReactDOM from "react-dom";
+import PropTypes from "prop-types";
+import styles from "../assets/stylesheets/presence-log.scss";
+import classNames from "classnames";
+import Linkify from "react-linkify";
+import { toArray as toEmojis } from "react-emoji-render";
+import serializeElement from "../utils/serialize-element";
+
+const messageCanvas = document.createElement("canvas");
+const emojiRegex = /(?:[\u2700-\u27bf]|(?:\ud83c[\udde6-\uddff]){2}|[\ud800-\udbff][\udc00-\udfff]|[\u0023-\u0039]\ufe0f?\u20e3|\u3299|\u3297|\u303d|\u3030|\u24c2|\ud83c[\udd70-\udd71]|\ud83c[\udd7e-\udd7f]|\ud83c\udd8e|\ud83c[\udd91-\udd9a]|\ud83c[\udde6-\uddff]|[\ud83c[\ude01-\ude02]|\ud83c\ude1a|\ud83c\ude2f|[\ud83c[\ude32-\ude3a]|[\ud83c[\ude50-\ude51]|\u203c|\u2049|[\u25aa-\u25ab]|\u25b6|\u25c0|[\u25fb-\u25fe]|\u00a9|\u00ae|\u2122|\u2139|\ud83c\udc04|[\u2600-\u26FF]|\u2b05|\u2b06|\u2b07|\u2b1b|\u2b1c|\u2b50|\u2b55|\u231a|\u231b|\u2328|\u23cf|[\u23e9-\u23f3]|[\u23f8-\u23fa]|\ud83c\udccf|\u2934|\u2935|[\u2190-\u21ff])/;
+const urlRegex = /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_+.~#?&//=]*)$/;
+
+const messageBodyDom = body => {
+  // Support wrapping text in ` to get monospace, and multiline.
+  const multiLine = body.split("\n").length > 1;
+  const mono = body.startsWith("`") && body.endsWith("`");
+  const messageBodyClasses = {
+    [styles.messageBody]: true,
+    [styles.messageBodyMulti]: multiLine,
+    [styles.messageBodyMono]: mono
+  };
+
+  const cleanedBody = (mono ? body.substring(1, body.length - 1) : body).trim();
+
+  return (
+    <div className={classNames(messageBodyClasses)}>
+      <Linkify properties={{ target: "_blank", rel: "noopener referrer" }}>{toEmojis(cleanedBody)}</Linkify>
+    </div>
+  );
+};
+
+export function spawnChatMessage(body) {
+  if (body.length === 0) return;
+
+  if (body.match(urlRegex)) {
+    document.querySelector("a-scene").emit("add_media", body);
+    return;
+  }
+
+  const isOneLine = body.split("\n").length === 1;
+  const context = messageCanvas.getContext("2d");
+  const emoji = toEmojis(body);
+  const isEmoji =
+    emoji.length === 1 && emoji[0].props && emoji[0].props.children.match && emoji[0].props.children.match(emojiRegex);
+
+  const el = document.createElement("div");
+  el.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
+  el.setAttribute("class", `${styles.presenceLog} ${styles.presenceLogSpawn}`);
+
+  // The element is added to the DOM in order to have layout compute the width & height,
+  // and then it is removed after being rendered.
+  document.body.appendChild(el);
+
+  const entryDom = (
+    <div
+      className={classNames({
+        [styles.presenceLogEntry]: !isEmoji,
+        [styles.presenceLogEntryOneLine]: !isEmoji && isOneLine,
+        [styles.presenceLogEmoji]: isEmoji
+      })}
+    >
+      {messageBodyDom(body)}
+    </div>
+  );
+
+  ReactDOM.render(entryDom, el, () => {
+    // Scale by 12x
+    messageCanvas.width = el.offsetWidth * 12.1;
+    messageCanvas.height = el.offsetHeight * 12.1;
+
+    const xhtml = encodeURIComponent(`
+        <svg xmlns="http://www.w3.org/2000/svg" width="${messageCanvas.width}" height="${messageCanvas.height}">
+          <foreignObject width="8.333%" height="8.333%" style="transform: scale(12.0);">
+            ${serializeElement(el)}
+          </foreignObject>
+        </svg>
+  `);
+    const img = new Image();
+
+    img.onload = async () => {
+      context.drawImage(img, 0, 0);
+      const blob = await new Promise(resolve => messageCanvas.toBlob(resolve));
+      document.querySelector("a-scene").emit("add_media", new File([blob], "message.png", { type: "image/png" }));
+      el.parentNode.removeChild(el);
+    };
+
+    img.src = "data:image/svg+xml," + xhtml;
+  });
+}
+
+export default function ChatMessage(props) {
+  const isOneLine = props.body.split("\n").length === 1;
+
+  return (
+    <div className={props.className}>
+      {props.maySpawn && <button className={styles.spawnMessage} onClick={() => spawnChatMessage(props.body)} />}
+      <div className={isOneLine ? styles.messageWrap : styles.messageWrapMulti}>
+        <div className={styles.messageSource}>
+          <b>{props.name}</b>:
+        </div>
+        {messageBodyDom(props.body)}
+      </div>
+    </div>
+  );
+}
+
+ChatMessage.propTypes = {
+  name: PropTypes.string,
+  maySpawn: PropTypes.bool,
+  body: PropTypes.string,
+  className: PropTypes.string
+};
diff --git a/src/react-components/presence-log.js b/src/react-components/presence-log.js
index d46550957f1eb7918b9310263ada1a2f64537dfb..491b98aa03d5586af55622ef905418f667773adc 100644
--- a/src/react-components/presence-log.js
+++ b/src/react-components/presence-log.js
@@ -2,9 +2,8 @@ import React, { Component } from "react";
 import PropTypes from "prop-types";
 import styles from "../assets/stylesheets/presence-log.scss";
 import classNames from "classnames";
-import Linkify from "react-linkify";
-import { toArray as toEmojis } from "react-emoji-render";
 import { FormattedMessage } from "react-intl";
+import ChatMessage from "./chat-message";
 
 export default class PresenceLog extends Component {
   static propTypes = {
@@ -19,6 +18,8 @@ export default class PresenceLog extends Component {
   domForEntry = e => {
     const entryClasses = {
       [styles.presenceLogEntry]: true,
+      [styles.presenceLogEntryWithButton]: e.type === "chat" && e.maySpawn,
+      [styles.presenceLogChat]: e.type === "chat",
       [styles.expired]: !!e.expired
     };
 
@@ -27,27 +28,30 @@ export default class PresenceLog extends Component {
       case "entered":
         return (
           <div key={e.key} className={classNames(entryClasses)}>
-            <b>{e.name}</b> <FormattedMessage id={`presence.${e.type}_${e.presence}`} />
+            <b>{e.name}</b>&nbsp;<FormattedMessage id={`presence.${e.type}_${e.presence}`} />
           </div>
         );
       case "leave":
         return (
           <div key={e.key} className={classNames(entryClasses)}>
-            <b>{e.name}</b> <FormattedMessage id={`presence.${e.type}`} />
+            <b>{e.name}</b>&nbsp;<FormattedMessage id={`presence.${e.type}`} />
           </div>
         );
       case "display_name_changed":
         return (
           <div key={e.key} className={classNames(entryClasses)}>
-            <b>{e.oldName}</b> <FormattedMessage id="presence.name_change" /> <b>{e.newName}</b>.
+            <b>{e.oldName}</b>&nbsp;<FormattedMessage id="presence.name_change" />&nbsp;<b>{e.newName}</b>.
           </div>
         );
       case "chat":
         return (
-          <div key={e.key} className={classNames(entryClasses)}>
-            <b>{e.name}</b>:{" "}
-            <Linkify properties={{ target: "_blank", rel: "noopener referrer" }}>{toEmojis(e.body)}</Linkify>
-          </div>
+          <ChatMessage
+            key={e.key}
+            name={e.name}
+            className={classNames(entryClasses)}
+            body={e.body}
+            maySpawn={e.maySpawn}
+          />
         );
       case "spawn": {
         const { src } = e.body;
@@ -58,13 +62,15 @@ export default class PresenceLog extends Component {
             </a>
             <div className={styles.mediaBody}>
               <span>
-                <b>{e.name}</b>:
+                <b>{e.name}</b>
               </span>
               <span>
                 {"took a "}
-                <a href={src} target="_blank" rel="noopener noreferrer">
-                  photo
-                </a>
+                <b>
+                  <a href={src} target="_blank" rel="noopener noreferrer">
+                    photo
+                  </a>
+                </b>.
               </span>
             </div>
           </div>
diff --git a/src/react-components/ui-root.js b/src/react-components/ui-root.js
index fe99b50763f2aa4d7cc9394b8a8ecd19565754bf..7a3987d2bb15f8b8dfa6ec8fafcdbac88d8478c4 100644
--- a/src/react-components/ui-root.js
+++ b/src/react-components/ui-root.js
@@ -30,6 +30,7 @@ import CreateObjectDialog from "./create-object-dialog.js";
 import PresenceLog from "./presence-log.js";
 import PresenceList from "./presence-list.js";
 import TwoDHUD from "./2d-hud";
+import { spawnChatMessage } from "./chat-message";
 import { faUsers } from "@fortawesome/free-solid-svg-icons/faUsers";
 
 import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -454,7 +455,7 @@ class UIRoot extends Component {
     if (!this.props.forcedVREntryType || !this.props.forcedVREntryType.endsWith("_now")) {
       this.goToEntryStep(ENTRY_STEPS.audio_setup);
     } else {
-      setTimeout(this.onAudioReadyButton, 3000); // Need to wait otherwise input doesn't work :/
+      this.onAudioReadyButton();
     }
   };
 
@@ -687,6 +688,9 @@ class UIRoot extends Component {
   };
 
   renderEntryStartPanel = () => {
+    const textRows = this.state.pendingMessage.split("\n").length;
+    const pendingMessageTextareaHeight = textRows * 28 + "px";
+    const pendingMessageFieldHeight = textRows * 28 + 20 + "px";
     const hasPush = navigator.serviceWorker && "PushManager" in window;
 
     return (
@@ -700,12 +704,21 @@ class UIRoot extends Component {
           </div>
 
           <form onSubmit={this.sendMessage}>
-            <div className={styles.messageEntry}>
-              <input
-                className={styles.messageEntryInput}
+            <div className={styles.messageEntry} style={{ height: pendingMessageFieldHeight }}>
+              <textarea
+                className={classNames([styles.messageEntryInput, "chat-focus-target"])}
                 value={this.state.pendingMessage}
+                rows={textRows}
+                style={{ height: pendingMessageTextareaHeight }}
                 onFocus={e => e.target.select()}
                 onChange={e => this.setState({ pendingMessage: e.target.value })}
+                onKeyDown={e => {
+                  if (e.keyCode === 13 && !e.shiftKey) {
+                    this.sendMessage(e);
+                  } else if (e.keyCode === 27) {
+                    e.target.blur();
+                  }
+                }}
                 placeholder="Send a message..."
               />
               <input className={styles.messageEntrySubmit} type="submit" value="send" />
@@ -999,6 +1012,10 @@ class UIRoot extends Component {
     const entryFinished = this.state.entryStep === ENTRY_STEPS.finished;
     const showVREntryButton = entryFinished && this.props.availableVREntryTypes.isInHMD;
 
+    const textRows = this.state.pendingMessage.split("\n").length;
+    const pendingMessageTextareaHeight = textRows * 28 + "px";
+    const pendingMessageFieldHeight = textRows * 28 + 20 + "px";
+
     return (
       <IntlProvider locale={lang} messages={messages}>
         <div className={styles.ui}>
@@ -1018,15 +1035,31 @@ class UIRoot extends Component {
           {entryFinished && <PresenceLog inRoom={true} entries={this.props.presenceLogEntries || []} />}
           {entryFinished && (
             <form onSubmit={this.sendMessage}>
-              <div className={styles.messageEntryInRoom}>
-                <input
-                  className={classNames([styles.messageEntryInput, styles.messageEntryInputInRoom])}
+              <div className={styles.messageEntryInRoom} style={{ height: pendingMessageFieldHeight }}>
+                <textarea
+                  style={{ height: pendingMessageTextareaHeight }}
+                  className={classNames([
+                    styles.messageEntryInput,
+                    styles.messageEntryInputInRoom,
+                    "chat-focus-target"
+                  ])}
                   value={this.state.pendingMessage}
+                  rows={textRows}
                   onFocus={e => e.target.select()}
                   onChange={e => {
                     e.stopPropagation();
                     this.setState({ pendingMessage: e.target.value });
                   }}
+                  onKeyDown={e => {
+                    if (e.keyCode === 13 && !e.shiftKey) {
+                      this.sendMessage(e);
+                    } else if (e.keyCode === 13 && e.shiftKey && e.ctrlKey) {
+                      spawnChatMessage(e.target.value);
+                      this.setState({ pendingMessage: "" });
+                    } else if (e.keyCode === 27) {
+                      e.target.blur();
+                    }
+                  }}
                   placeholder="Send a message..."
                 />
                 <input
@@ -1034,6 +1067,17 @@ class UIRoot extends Component {
                   type="submit"
                   value="send"
                 />
+                <button
+                  className={classNames([styles.messageEntrySpawn])}
+                  onClick={() => {
+                    if (this.state.pendingMessage.length > 0) {
+                      spawnChatMessage(this.state.pendingMessage);
+                      this.setState({ pendingMessage: "" });
+                    } else {
+                      this.showCreateObjectDialog();
+                    }
+                  }}
+                />
               </div>
             </form>
           )}
diff --git a/src/scene-entry-manager.js b/src/scene-entry-manager.js
index 6974195ca86bd67bd6f60d2c75f67e9593d7f744..f109520adbee6f715339416988eccfa502432db7 100644
--- a/src/scene-entry-manager.js
+++ b/src/scene-entry-manager.js
@@ -218,7 +218,7 @@ export default class SceneEntryManager {
     });
 
     document.addEventListener("paste", e => {
-      if (e.target.nodeName === "INPUT" && document.activeElement === e.target) return;
+      if (e.matches("input, textarea") && document.activeElement === e.target) return;
 
       const url = e.clipboardData.getData("text");
       const files = e.clipboardData.files && e.clipboardData.files;
diff --git a/src/systems/userinput/bindings/keyboard-mouse-user.js b/src/systems/userinput/bindings/keyboard-mouse-user.js
index 12e3b3e4f64b814c06427030a7b5e9be7d6aaf0a..3179ade73b65105bd2410e94505bf6b79653aecb 100644
--- a/src/systems/userinput/bindings/keyboard-mouse-user.js
+++ b/src/systems/userinput/bindings/keyboard-mouse-user.js
@@ -130,6 +130,15 @@ export const keyboardMouseUserBindings = {
       },
       xform: xforms.rising
     },
+    {
+      src: {
+        value: paths.device.keyboard.key("t")
+      },
+      dest: {
+        value: paths.actions.focusChat
+      },
+      xform: xforms.rising
+    },
     {
       src: {
         value: paths.device.keyboard.key("l")
diff --git a/src/systems/userinput/paths.js b/src/systems/userinput/paths.js
index d9f74a9ac6e437ead10d2e57237275c2de44aa44..4d5b0fd9dbb242be53f9d8fb938353add5e353de 100644
--- a/src/systems/userinput/paths.js
+++ b/src/systems/userinput/paths.js
@@ -13,6 +13,7 @@ paths.actions.startGazeTeleport = "/actions/startTeleport";
 paths.actions.stopGazeTeleport = "/actions/stopTeleport";
 paths.actions.spawnPen = "/actions/spawnPen";
 paths.actions.muteMic = "/actions/muteMic";
+paths.actions.focusChat = "/actions/focusChat";
 paths.actions.cursor = {};
 paths.actions.cursor.pose = "/actions/cursorPose";
 paths.actions.cursor.grab = "/actions/cursorGrab";
diff --git a/src/utils/serialize-element.js b/src/utils/serialize-element.js
new file mode 100644
index 0000000000000000000000000000000000000000..7782d331bc98aef445b143e4a43af6a5c71fd9a4
--- /dev/null
+++ b/src/utils/serialize-element.js
@@ -0,0 +1,191 @@
+// https://stackoverflow.com/questions/6209161/extract-the-current-dom-and-print-it-as-a-string-with-styles-intact
+//
+// Mapping between tag names and css default values lookup tables. This allows to exclude default values in the result.
+const defaultStylesByTagName = {};
+
+// Styles inherited from style sheets will not be rendered for elements with these tag names
+const noStyleTags = {
+  BASE: true,
+  HEAD: true,
+  HTML: true,
+  META: true,
+  NOFRAME: true,
+  NOSCRIPT: true,
+  PARAM: true,
+  SCRIPT: true,
+  STYLE: true,
+  TITLE: true
+};
+
+// This list determines which css default values lookup tables are precomputed at load time
+// Lookup tables for other tag names will be automatically built at runtime if needed
+const tagNames = [
+  "A",
+  "ABBR",
+  "ADDRESS",
+  "AREA",
+  "ARTICLE",
+  "ASIDE",
+  "AUDIO",
+  "B",
+  "BASE",
+  "BDI",
+  "BDO",
+  "BLOCKQUOTE",
+  "BODY",
+  "BR",
+  "BUTTON",
+  "CANVAS",
+  "CAPTION",
+  "CENTER",
+  "CITE",
+  "CODE",
+  "COL",
+  "COLGROUP",
+  "COMMAND",
+  "DATALIST",
+  "DD",
+  "DEL",
+  "DETAILS",
+  "DFN",
+  "DIV",
+  "DL",
+  "DT",
+  "EM",
+  "EMBED",
+  "FIELDSET",
+  "FIGCAPTION",
+  "FIGURE",
+  "FONT",
+  "FOOTER",
+  "FORM",
+  "H1",
+  "H2",
+  "H3",
+  "H4",
+  "H5",
+  "H6",
+  "HEAD",
+  "HEADER",
+  "HGROUP",
+  "HR",
+  "HTML",
+  "I",
+  "IFRAME",
+  "IMG",
+  "INPUT",
+  "INS",
+  "KBD",
+  "KEYGEN",
+  "LABEL",
+  "LEGEND",
+  "LI",
+  "LINK",
+  "MAP",
+  "MARK",
+  "MATH",
+  "MENU",
+  "META",
+  "METER",
+  "NAV",
+  "NOBR",
+  "NOSCRIPT",
+  "OBJECT",
+  "OL",
+  "OPTION",
+  "OPTGROUP",
+  "OUTPUT",
+  "P",
+  "PARAM",
+  "PRE",
+  "PROGRESS",
+  "Q",
+  "RP",
+  "RT",
+  "RUBY",
+  "S",
+  "SAMP",
+  "SCRIPT",
+  "SECTION",
+  "SELECT",
+  "SMALL",
+  "SOURCE",
+  "SPAN",
+  "STRONG",
+  "STYLE",
+  "SUB",
+  "SUMMARY",
+  "SUP",
+  "SVG",
+  "TABLE",
+  "TBODY",
+  "TD",
+  "TEXTAREA",
+  "TFOOT",
+  "TH",
+  "THEAD",
+  "TIME",
+  "TITLE",
+  "TR",
+  "TRACK",
+  "U",
+  "UL",
+  "VAR",
+  "VIDEO",
+  "WBR"
+];
+
+function computeDefaultStyleByTagName(tagName) {
+  const defaultStyle = {};
+  const element = document.body.appendChild(document.createElement(tagName));
+  const computedStyle = getComputedStyle(element);
+  for (let i = 0; i < computedStyle.length; i++) {
+    defaultStyle[computedStyle[i]] = computedStyle[computedStyle[i]];
+  }
+  document.body.removeChild(element);
+  return defaultStyle;
+}
+
+function getDefaultStyleByTagName(tagName) {
+  tagName = tagName.toUpperCase();
+  if (!defaultStylesByTagName[tagName]) {
+    defaultStylesByTagName[tagName] = computeDefaultStyleByTagName(tagName);
+  }
+  return defaultStylesByTagName[tagName];
+}
+
+export default function serializeElement(el) {
+  if (Object.keys(defaultStylesByTagName).length === 0) {
+    // Precompute the lookup tables.
+    for (let i = 0; i < tagNames.length; i++) {
+      if (!noStyleTags[tagNames[i]]) {
+        defaultStylesByTagName[tagNames[i]] = computeDefaultStyleByTagName(tagNames[i]);
+      }
+    }
+  }
+
+  if (el.nodeType !== Node.ELEMENT_NODE) {
+    throw new TypeError();
+  }
+  const cssTexts = [];
+  const elements = el.querySelectorAll("*");
+  for (let i = 0; i < elements.length; i++) {
+    const e = elements[i];
+    if (!noStyleTags[e.tagName]) {
+      const computedStyle = getComputedStyle(e);
+      const defaultStyle = getDefaultStyleByTagName(e.tagName);
+      cssTexts[i] = e.style.cssText;
+      for (let ii = 0; ii < computedStyle.length; ii++) {
+        const cssPropName = computedStyle[ii];
+        if (computedStyle[cssPropName] !== defaultStyle[cssPropName]) {
+          e.style[cssPropName] = computedStyle[cssPropName];
+        }
+      }
+    }
+  }
+  const result = el.outerHTML;
+  for (let i = 0; i < elements.length; i++) {
+    elements[i].style.cssText = cssTexts[i];
+  }
+  return result;
+}