diff --git a/crates/treehouse/src/html/tree.rs b/crates/treehouse/src/html/tree.rs
index 816c1e2..7647239 100644
--- a/crates/treehouse/src/html/tree.rs
+++ b/crates/treehouse/src/html/tree.rs
@@ -57,13 +57,13 @@ pub fn branch_to_html(
 
     let linked_branch = if let Content::ResolvedLink(file_id) = &branch.attributes.content {
         let path = treehouse.tree_path(*file_id).expect(".tree file expected");
-        format!(" data-th-link=\"{}\"", EscapeHtml(path.as_str()))
+        format!(" th-link=\"{}\"", EscapeHtml(path.as_str()))
     } else {
         String::new()
     };
 
     let do_not_persist = if branch.attributes.do_not_persist {
-        " data-th-do-not-persist=\"\""
+        " th-do-not-persist"
     } else {
         ""
     };
@@ -165,7 +165,7 @@ pub fn branch_to_html(
             } else {
                 write!(
                     s,
-                    "<a class=\"icon icon-permalink\" href=\"/b?{}\" title=\"permalink\"></a>",
+                    "<a th-p class=\"icon icon-permalink\" href=\"/b?{}\" title=\"permalink\"></a>",
                     EscapeAttribute(&branch.named_id)
                 )
                 .unwrap();
diff --git a/static/css/main.css b/static/css/main.css
index ad3ceea..40e59ee 100644
--- a/static/css/main.css
+++ b/static/css/main.css
@@ -611,21 +611,26 @@ img[data-cast~="emoji"] {
     object-fit: contain;
 }
 
-/* And also style emoji tooltips. */
+/* Tooltips */
 
-th-emoji-tooltip {
-    display: flex;
-    flex-direction: column;
-    align-items: center;
+th-overlays {
+    position: absolute;
+    left: 0;
+    top: 0;
+    width: 100%;
+    height: 100%;
+    pointer-events: none;
+}
+
+th-tooltip {
+    display: block;
 
     position: fixed;
-    transform: translateX(-50%) translateY(-10%) scale(0.8);
     width: max-content;
     z-index: 100;
 
     background-color: var(--background-color-tooltip);
-    padding: 0.8rem;
-    margin-top: 0.8rem;
+    padding: 0.4rem 0.8rem;
     border-radius: 0.6rem;
 
     transition:
@@ -635,27 +640,51 @@ th-emoji-tooltip {
     opacity: 0%;
     filter: blur(0.3rem);
     pointer-events: none;
+
+    font-size: 0.9em;
+
+    &[th-side="bottom"] {
+        transform: translateX(-50%) translateY(-10%) scale(0.8);
+
+        &.transitioned-in {
+            transform: translateX(-50%) scale(1);
+        }
+    }
+
+    &[th-side="left"] {
+        transform: translateX(-90%) translateY(-50%) scale(0.8);
+
+        &.transitioned-in {
+            transform: translateX(-100%) translateY(-50%);
+        }
+    }
 }
 
-th-emoji-tooltip.transitioned-in {
+th-tooltip.transitioned-in {
     opacity: 100%;
     filter: blur(0);
-    transform: translateX(-50%) scale(1);
 }
 
-th-emoji-tooltip img {
-    display: block;
-    max-width: 7.2rem;
-    max-height: 7.2rem;
-}
+th-tooltip.tooltip-emoji {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
 
-th-emoji-tooltip p {
-    --recursive-wght: 550;
-    color: var(--text-color);
-    font-size: 0.9em;
-    margin: 0;
-    padding-top: 0.6rem;
-    line-height: 1;
+    padding: 0.8rem;
+    margin-top: 0.8rem;
+
+    & > img {
+        display: block;
+        max-width: 7.2rem;
+        max-height: 7.2rem;
+    }
+
+    & > p {
+        color: var(--text-color);
+        margin: 0;
+        padding-top: 0.6rem;
+        line-height: 1;
+    }
 }
 
 .th-emoji-unknown {
diff --git a/static/css/tree.css b/static/css/tree.css
index 9d5ba19..b26444c 100644
--- a/static/css/tree.css
+++ b/static/css/tree.css
@@ -239,7 +239,7 @@ th-bc {
 .tree details:not([open])>summary>th-bc>:last-child,
 /* NOTE: Linked branches have a slightly different structure (extra <noscript> tag) and therefore
    :last-child does not work. */
-.tree li[data-th-link]>details:not([open])>summary>th-bc>:nth-last-child(2) {
+.tree li[th-link]>details:not([open])>summary>th-bc>:nth-last-child(2) {
     &::after {
         content: "\00A0";
         display: inline-block;
diff --git a/static/js/emoji.js b/static/js/emoji.js
index d82fb89..13a04cf 100644
--- a/static/js/emoji.js
+++ b/static/js/emoji.js
@@ -1,103 +1,21 @@
 // Emoji zoom-in functionality.
 
 import { addSpell } from "treehouse/spells.js";
+import { attachTooltip, Tooltip } from "treehouse/overlay.js";
 
-class EmojiTooltip extends HTMLElement {
-    constructor(emoji, element, { onClosed }) {
-        super();
+function createEmojiTooltip(emoji, element) {
+    let tooltip = new Tooltip(element, "bottom");
+    tooltip.classList.add("tooltip-emoji");
 
-        this.emoji = emoji;
-        this.emojiElement = element;
-        this.onClosed = onClosed;
-    }
+    let img = tooltip.appendChild(new Image());
+    img.src = element.src;
 
-    connectedCallback() {
-        this.role = "tooltip";
+    let description = tooltip.appendChild(document.createElement("p"));
+    description.textContent = emoji.emojiName;
 
-        this.image = new Image();
-        this.image.src = this.emojiElement.src;
-
-        this.description = document.createElement("p");
-        this.description.textContent = `${this.emoji.emojiName}`;
-
-        let emojiBoundingBox = this.emojiElement.getBoundingClientRect();
-        this.style.left = `${emojiBoundingBox.left + emojiBoundingBox.width / 2}px`;
-        this.style.top = `calc(${emojiBoundingBox.top}px + 1.5em)`;
-
-        this.fullyOpaque = false;
-        this.addEventListener("transitionend", event => {
-            if (event.propertyName == "opacity") {
-                this.fullyOpaque = !this.fullyOpaque;
-                if (!this.fullyOpaque) {
-                    this.onClosed();
-                }
-            }
-        });
-        // Timeout is zero because we just want to execute this later, to be definitely sure
-        // the transition plays out.
-        setTimeout(() => this.classList.add("transitioned-in"), 0);
-
-        this.appendChild(this.image);
-        this.appendChild(this.description);
-    }
-
-    close() {
-        this.classList.remove("transitioned-in");
-    }
+    return tooltip;
 }
 
-customElements.define("th-emoji-tooltip", EmojiTooltip);
-
-let emojiTooltips = null;
-
-class EmojiTooltips extends HTMLElement {
-    constructor() {
-        super();
-        this.tooltips = new Set();
-        this.abortController = new AbortController();
-    }
-
-    connectedCallback() {
-        emojiTooltips = this;
-
-        addEventListener(
-            "wheel",
-            event => emojiTooltips.closeTooltips(event),
-            { signal: this.abortController.signal },
-        );
-    }
-
-    disconnectedCallback() {
-        this.abortController.abort();
-    }
-
-    openTooltip(emoji, element) {
-        let tooltip = new EmojiTooltip(emoji, element, {
-            onClosed: () => {
-                this.removeChild(tooltip);
-                this.tooltips.delete(tooltip);
-            },
-        });
-
-        this.appendChild(tooltip);
-        this.tooltips.add(tooltip);
-
-        return tooltip;
-    }
-
-    closeTooltip(tooltip) {
-        tooltip.close();
-    }
-
-    closeTooltips() {
-        for (let tooltip of this.tooltips) {
-            tooltip.close();
-        }
-    }
-}
-
-customElements.define("th-emoji-tooltips", EmojiTooltips);
-
 class Emoji {
     constructor(element) {
         this.emojiName = element.title;
@@ -106,18 +24,7 @@ class Emoji {
         // so remove the title.
         element.title = "";
 
-        element.addEventListener("mouseenter", () => this.openTooltip(element));
-        element.addEventListener("mouseleave", () => this.closeTooltip());
-        element.addEventListener("scroll", () => this.closeTooltip());
-    }
-
-    openTooltip(element) {
-        this.tooltip = emojiTooltips.openTooltip(this, element);
-    }
-
-    closeTooltip() {
-        emojiTooltips.closeTooltip(this.tooltip);
-        this.tooltip = null;
+        attachTooltip(element, () => createEmojiTooltip(this, element)).showOnHover();
     }
 }
 
diff --git a/static/js/overlay.js b/static/js/overlay.js
new file mode 100644
index 0000000..fd2c70d
--- /dev/null
+++ b/static/js/overlay.js
@@ -0,0 +1,116 @@
+export class Overlay extends HTMLElement {}
+
+/** @type Overlays */
+export let overlays = null;
+
+export class Overlays extends HTMLElement {
+    overlays = new Set();
+
+    connectedCallback() {
+        overlays = this;
+    }
+
+    disconnectedCallback() {
+        overlays = null;
+    }
+
+    open(overlay) {
+        this.appendChild(overlay);
+        this.overlays.add(overlay);
+        return overlay;
+    }
+
+    close(overlay) {
+        this.removeChild(overlay);
+        this.overlays.delete(overlay);
+    }
+}
+
+customElements.define("th-overlays", Overlays);
+
+export class Tooltip extends Overlay {
+    constructor(element, side) {
+        super();
+
+        this.element = element;
+        this.side = side;
+    }
+
+    connectedCallback() {
+        this.role = "tooltip";
+        this.setAttribute("th-side", this.side);
+
+        let bb = this.element.getBoundingClientRect();
+        switch (this.side) {
+            // NOTE: The elements are positioned directly at (width / 2) or (height / 2), because
+            // they are transformed to the centre over on the CSS side.
+
+            case "bottom":
+                this.style.left = `${bb.left + bb.width / 2}px`;
+                this.style.top = `${bb.bottom}px`;
+                break;
+
+            case "left":
+                this.style.left = `${bb.left}px`;
+                this.style.top = `${bb.top + bb.height / 2}px`;
+                break;
+
+            default:
+                console.error(`th-tooltip: unknown attachment side ${this.side}`);
+                break;
+        }
+
+        this.addEventListener("transitionend", (event) => {
+            if (event.propertyName == "opacity") {
+                let style = getComputedStyle(this);
+                if (style.opacity < 0.01) {
+                    this.dispatchEvent(new Event(".close"));
+                }
+            }
+        });
+        // Timeout is zero because we just want to execute this later, to be definitely sure
+        // the transition plays out.
+        setTimeout(() => this.classList.add("transitioned-in"), 0);
+    }
+
+    close() {
+        this.classList.remove("transitioned-in");
+
+        // NOTE: In case there is no transition, we may need to trigger the close event immediately.
+        let style = getComputedStyle(this);
+        if (style.opacity < 0.01) {
+            this.dispatchEvent(new Event(".close"));
+        }
+    }
+}
+
+customElements.define("th-tooltip", Tooltip);
+
+export function attachTooltip(element, makeTooltip) {
+    let show = () => {
+        let tooltip = overlays.open(makeTooltip(element));
+        let abortController = new AbortController();
+
+        tooltip.addEventListener(".close", () => {
+            overlays.close(tooltip);
+            abortController.abort();
+            console.log("closing tooltip");
+        });
+
+        window.addEventListener("wheel", () => tooltip.close(), {
+            signal: abortController.signal,
+            passive: true,
+        });
+        element.addEventListener("mouseleave", () => tooltip.close(), {
+            signal: abortController.signal,
+        });
+    };
+
+    return {
+        show,
+        showOnHover() {
+            element.addEventListener("mouseenter", show);
+            return this;
+        },
+    };
+}
diff --git a/static/js/tree.js b/static/js/tree.js
index 0750253..658147e 100644
--- a/static/js/tree.js
+++ b/static/js/tree.js
@@ -2,6 +2,7 @@
 
 import { addSpell } from "treehouse/spells.js";
 import * as ulid from "treehouse/ulid.js";
+import { attachTooltip, Tooltip } from "treehouse/overlay.js";
 
 /* Branch persistence */
 
@@ -38,7 +39,7 @@ export class Branch {
         this.branchContent = this.contentContainer.childNodes[1];
         this.buttonBar = this.contentContainer.childNodes[2];
 
-        let doPersist = !element.hasAttribute("data-th-do-not-persist");
+        let doPersist = !element.hasAttribute("th-do-not-persist");
         let isOpen = branchIsOpen(element.id);
         if (doPersist && isOpen !== undefined) {
             this.details.open = isOpen;
@@ -52,6 +53,22 @@ export class Branch {
         this.namedID = element.id.replace(/^b-/, "");
         Branch.branchesByNamedID.set(this.namedID, element);
 
+        let permalinkButton = this.buttonBar.querySelector("a[th-p]");
+        if (permalinkButton != null) {
+            permalinkButton.title = "copy permalink";
+            permalinkButton.addEventListener("click", (event) => {
+                event.preventDefault(); // do not navigate the link
+                navigator.clipboard.writeText(
+                    new URL(permalinkButton.href, window.location).toString(),
+                );
+                attachTooltip(permalinkButton, () => {
+                    let tooltip = new Tooltip(permalinkButton, "left");
+                    tooltip.append("permalink copied to clipboard!");
+                    return tooltip;
+                }).show();
+            });
+        }
+
         if (ulid.isCanonicalUlid(this.namedID)) {
             let timestamp = ulid.getTimestamp(this.namedID);
             let date = document.createElement("span");
@@ -76,7 +93,7 @@ class LinkedBranch extends Branch {
     constructor(element) {
         super(element);
 
-        this.linkedTree = element.getAttribute("data-th-link");
+        this.linkedTree = element.getAttribute("th-link");
         LinkedBranch.byLink.set(this.linkedTree, this);
 
         this.loadingText = document.createElement("p");
@@ -135,7 +152,7 @@ class LinkedBranch extends Branch {
         }
     }
 
-    loadTree() {
+    loadTree(_why) {
         if (!this.loading) {
             this.loading = this.loadTreePromise();
         }
@@ -157,16 +174,16 @@ function expandDetailsRecursively(element) {
 }
 
 function getCurrentlyHighlightedBranch() {
-    if (window.location.pathname == "/b" && window.location.search.length > 0) {
-        let shortID = window.location.search.substring(1);
-        return Branch.branchesByNamedID.get(shortID).id;
-    } else {
+    if (window.location.hash.length > 0) {
         return window.location.hash.substring(1);
     }
 }
 
 async function highlightCurrentBranch() {
-    let branch = document.getElementById(getCurrentlyHighlightedBranch());
+    let branchId = getCurrentlyHighlightedBranch();
+    if (branchId == null) return;
+
+    let branch = document.getElementById(branchId);
     if (branch != null) {
         branch.scrollIntoView();
 
diff --git a/template/_tree.hbs b/template/_tree.hbs
index 8f2991b..f395225 100644
--- a/template/_tree.hbs
+++ b/template/_tree.hbs
@@ -27,7 +27,7 @@
     {{> components/_footer.hbs }}
     {{/if}}
 
-    <th-emoji-tooltips></th-emoji-tooltips>
+    <th-overlays></th-overlays>
     <th-command-line></th-command-line>
 </body>
 
diff --git a/template/components/_head.hbs b/template/components/_head.hbs
index 091d470..cf19b2e 100644
--- a/template/components/_head.hbs
+++ b/template/components/_head.hbs
@@ -45,6 +45,7 @@ clever to do while browser vendors figure that out, we'll just have to do a cach
     import "treehouse/tree.js";
     import "treehouse/emoji.js";
     import "treehouse/command-line.js";
+    import "treehouse/overlay.js";
 </script>
 
 <meta property="og:site_name" content="{{ config.user.title }}">