diff --git a/lookatme/output/templates/styles.template.css b/lookatme/output/templates/styles.template.css
index b78b603..313f663 100644
--- a/lookatme/output/templates/styles.template.css
+++ b/lookatme/output/templates/styles.template.css
@@ -228,6 +228,7 @@ body {
     top: 0;
     color: #2c2c2c;
     background-color: {{styles.scrollbar.gutter.bg}};
+    user-select: none;
 }
 
 .scrollbar-slider {
@@ -235,6 +236,7 @@ body {
     top: 0;
     z-index: 200;
     color: #4c4c4c;
+    user-select: none;
 }
 
 @keyframes blinker {
diff --git a/lookatme/render/html/__init__.py b/lookatme/render/html/__init__.py
index a77226b..c74d0c2 100644
--- a/lookatme/render/html/__init__.py
+++ b/lookatme/render/html/__init__.py
@@ -5,6 +5,7 @@ This module renders lookatme slides to HTML
 
 from collections import deque
 from contextlib import contextmanager
+import re
 from typing import Dict, Optional
 
 
@@ -111,12 +112,7 @@ class HtmlContext:
 
 
 def _sanitize(text: str) -> str:
-    return (
-        text.replace("&", "&amp;")
-        .replace(" ", "&nbsp;")
-        .replace("<", "&lt;")
-        .replace(">", "&gt;")
-    )
+    return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
 
 
 def _keep_text(text_idx: int, text: str, keep_range: range) -> str:
@@ -134,6 +130,11 @@ def _keep_text(text_idx: int, text: str, keep_range: range) -> str:
     return text[start - text_idx : stop - text_idx]
 
 
+def _space_span_replace(match: re.Match) -> str:
+    spaces = match.group(0)
+    return f"<span style='padding-left: {len(spaces)}ch'></span>"
+
+
 def canvas_to_html(
     ctx: HtmlContext,
     canvas: urwid.Canvas,
@@ -157,6 +158,10 @@ def canvas_to_html(
                 text = new_text
 
             text = _sanitize(text)
+            if getattr(spec, "preserve_spaces", False):
+                text = text.replace(" ", "&nbsp;")
+            else:
+                text = re.sub(r"( {2,})", _space_span_replace, text)
 
             if text == "":
                 continue
diff --git a/lookatme/render/markdown_inline.py b/lookatme/render/markdown_inline.py
index 5329514..ceabdce 100644
--- a/lookatme/render/markdown_inline.py
+++ b/lookatme/render/markdown_inline.py
@@ -307,6 +307,7 @@ def render_code_inline(token, ctx: Context):
 
     for spec, text in markups:
         spec = ctx.spec_text_with(spec)
+        spec.preserve_spaces = True
         ctx.inline_push((spec, text))
 
 
diff --git a/lookatme/widgets/codeblock.py b/lookatme/widgets/codeblock.py
index f68a674..6d0f0d9 100644
--- a/lookatme/widgets/codeblock.py
+++ b/lookatme/widgets/codeblock.py
@@ -140,6 +140,7 @@ class SyntaxHlStyle:
             )["fg"]
 
         colors.ensure_contrast(spec)
+        spec.preserve_spaces = True
 
         return spec
 
diff --git a/lookatme/widgets/smart_attr_spec.py b/lookatme/widgets/smart_attr_spec.py
index ec11807..f51a6cd 100644
--- a/lookatme/widgets/smart_attr_spec.py
+++ b/lookatme/widgets/smart_attr_spec.py
@@ -2,6 +2,8 @@ import urwid
 
 
 class SmartAttrSpec(urwid.AttrSpec):
+    preserve_spaces: bool = False
+
     def __init__(self, fg, bg):
         """An attr spec that chooses the right color depth"""
         super().__init__(fg, bg, 2**24)

diff --git a/tests/outputs/test_html_raw.py b/tests/outputs/test_html_raw.py
index d8208e5..9985627 100644
--- a/tests/outputs/test_html_raw.py
+++ b/tests/outputs/test_html_raw.py
@@ -13,10 +13,6 @@ import lookatme.output
 from lookatme.pres import Presentation
 
 
-def htmld(val: str) -> str:
-    return str.replace(val, " ", "&nbsp;")
-
-
 def test_html_raw_output_defined():
     assert "html_raw" in lookatme.output.get_all_formats()
 
@@ -78,9 +74,14 @@ def test_basic_raw_slide_generation(tmpdir):
         with open(file, "r") as f:
             data = f.read()
 
-        assert htmld(f"Slide {idx + 1}") in data
-        assert htmld(f"slide {idx + 1} / {len(html_files)}") in data
+        assert f"Slide {idx + 1}" in data
+        assert f"slide {idx + 1} / {len(html_files)}" in data
 
         assert data.count("<br/>") == rows - 1
         lines = data.split("<br/>")
-        assert lines[1].count("&nbsp;") == cols
+
+        total_spaces = 0
+        for match in re.finditer(r"style='padding-left: (\d+)ch'", lines[1]):
+            total_spaces += int(match.group(1))
+
+        assert total_spaces == cols

HTML output needs better rendering
**Describe the bug**

Currently the html output is a character-for-character exact match of what you would see rendered in the terminal (minus any font issues, like braille characters missing from the Deja Vu Sans Mono font - #211).

We need a better way to have a character-for-character version of what you would see in the terminal. A smarter approach to dealing with background colors, and containers, would make:

* colors easier to handle
* text copy-pasteable
* maybe let us use flow elements?

This type of rendering output would probably require us to do our own recursive rendering of urwid elements. Not impossible at all, but it's probably the best way to do it.

**Additional context**

Some other thoughts:

* Make heavy use of the `ch` css unit for padding/margins (character width)
tests/outputs/test_html_raw.py::test_basic_raw_slide_generation
tests/outputs/test_html_raw.py::test_html_raw_output_defined