Component

Selection Toolbar

A floating toolbar that appears above any selected text inside its article scope, offering Highlight (real <mark> wrap), Copy, and Share actions. Positions itself over the selection with viewport clamping and re-tracks on scroll and resize.

  • Component
  • Tailwind
  • HTML
  • MIT

Preview

Live component

Interactive preview
Open preview
9:41 100%
devsnips.dev/library/tailwind/components/tooltips/selection-toolbar/
fluid · no fixed width 100%
No dependencies Production-ready

Install

Add to your project

terminal
npx devsnips add Tailwind/Components/Tooltips/selection-toolbar
registry path: Tailwind/Components/Tooltips/selection-toolbar

Source

Implementation

code.html
<!--
Snippet Name: Selection Toolbar
Description: A floating neo-brutalist toolbar that appears over selected text offering highlight, copy, and share actions.
Author: DevSnips Contributors
Usage Example: Wrap any article with the data-selection-toolbar scope; selecting prose opens the toolbar. Highlight wraps the range in a <mark>.
-->
<style>
  [data-selection-toolbar="selection-toolbar"] ::selection { background: #FFE600; color: #0a0a0a; }
</style>
<div data-selection-toolbar="selection-toolbar" class="relative">
  <article class="border-2 border-black bg-white">
    <header class="border-b-2 border-black p-6 pb-5 sm:p-8 sm:pb-6">
      <span class="inline-block rounded-none bg-[#FFE600] px-2 py-1 text-xs font-bold uppercase tracking-widest text-black">Design Systems Weekly · Essay №42</span>
      <h2 class="mt-4 text-3xl font-black tracking-tight text-black sm:text-4xl">Polish is a product feature</h2>
      <div class="mt-4 flex flex-wrap items-center gap-x-3 gap-y-2 text-sm text-neutral-600">
        <span class="flex items-center gap-2">
          <span aria-hidden="true" class="flex h-6 w-6 items-center justify-center border-2 border-black bg-[#FFE600] text-[11px] font-black text-black">MO</span>
          <span class="font-semibold text-black">Marina Okafor</span>
        </span>
        <span aria-hidden="true" class="h-4 w-px bg-neutral-300"></span>
        <span>Aug 25, 2026</span>
        <span aria-hidden="true" class="h-4 w-px bg-neutral-300"></span>
        <span>5 min read</span>
        <span aria-hidden="true" class="h-4 w-px bg-neutral-300"></span>
        <span data-highlights-count class="border-b-2 border-[#FFE600] font-medium text-black">1 highlight</span>
      </div>
    </header>

    <div class="px-6 py-6 sm:px-8 sm:py-8 text-[15px] sm:text-base leading-relaxed text-neutral-800">
      <p>
        Every product team has a version of the same meeting. The feature works, the tests pass,
        and someone on the call says the interface still <em>feels off</em> in a way no ticket can
        quite capture. The room treats that feeling as a luxury, and the polish it asks for goes
        to the bottom of the backlog. That is a mistake — and it compounds.
      </p>
      <p class="mt-4">
        Rough edges accumulate the way interest does. A focus ring that never shows, a tooltip that
        flickers, a button that does not visibly respond to press — none of these block a task on
        their own, yet together they teach people to trust the product a little less with every
        interaction. <mark class="bg-[#FFE600] px-0.5 font-medium text-black">Rough edges compound.</mark>
        Users may never quote the defect, but they will feel the average of every one of them.
      </p>
      <p class="mt-4">
        The fix is not heroics on a launch week. It is a standing budget for the last 1%: motion
        tuned to 150ms ease-out, selection states that announce themselves, empty states written
        like a colleague instead of a linter. Pick the details most worth sweating and make them
        part of done.
      </p>
      <blockquote class="mt-6 border-l-4 border-black bg-neutral-50 px-4 py-3 font-medium text-black">
        Users never separate polish from quality. To them, one is simply evidence of the other.
      </blockquote>
      <p class="mt-4">
        Polish is not the coat of paint applied after engineering. It is the difference between a
        tool that is merely correct and one that is a pleasure to operate — and it is why the
        interface, not the feature list, is where brand gets built. Sweat it deliberately, or
        watch it leak.
      </p>
    </div>
  </article>

  <div
    data-st-toolbar
    role="toolbar"
    aria-label="Text formatting"
    class="fixed z-50 hidden items-stretch gap-0 border-2 border-black bg-black shadow-[6px_6px_0_0_rgba(0,0,0,0.9)]"
  >
    <button
      type="button"
      data-st-highlight
      class="flex h-11 items-center gap-1.5 bg-[#FFE600] px-3 text-sm font-bold text-black transition-colors duration-150 hover:bg-[#FF4FA3] focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white motion-reduce:transition-none"
    >
      <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.4" aria-hidden="true">
        <path stroke-linecap="round" stroke-linejoin="round" d="M15.2 4.3 19.7 8.8m-8.9-4.8 8.9 8.9-4.2 4.2-8.9-8.9 4.2-4.2ZM6.1 12.5l1.4 5.8 5.3-1.4" />
      </svg>
      Highlight
    </button>
    <button
      type="button"
      data-st-copy
      class="flex h-11 items-center gap-1.5 px-3 text-sm font-semibold text-white transition-colors duration-150 hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[#FFE600] motion-reduce:transition-none"
    >
      <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" aria-hidden="true">
        <path stroke-linecap="round" stroke-linejoin="round" d="M8 8V5.6A1.6 1.6 0 0 1 9.6 4h8.8A1.6 1.6 0 0 1 20 5.6v8.8a1.6 1.6 0 0 1-1.6 1.6H16M4 9.6A1.6 1.6 0 0 1 5.6 8h8.8A1.6 1.6 0 0 1 16 9.6v8.8a1.6 1.6 0 0 1-1.6 1.6H5.6A1.6 1.6 0 0 1 4 18.4V9.6Z" />
      </svg>
      <span data-st-copy-label>Copy</span>
    </button>
    <button
      type="button"
      data-st-share
      class="flex h-11 items-center gap-1.5 px-3 text-sm font-semibold text-white transition-colors duration-150 hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[#FFE600] motion-reduce:transition-none"
    >
      <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" aria-hidden="true">
        <path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0-12L7.5 8.5M12 4l4.5 4.5M5 13v5.5A1.5 1.5 0 0 0 6.5 20h11a1.5 1.5 0 0 0 1.5-1.5V13" />
      </svg>
      <span data-st-share-label>Share</span>
    </button>
    <span aria-hidden="true" class="w-px bg-white/20"></span>
    <span data-st-count aria-hidden="true" class="flex items-center px-3 text-xs font-medium tabular-nums text-white/50"></span>
  </div>

  <span data-st-live class="sr-only" role="status" aria-live="polite"></span>

  <script>
    (function () {
      var root = document.currentScript.closest('[data-selection-toolbar="selection-toolbar"]');
      var article = root.querySelector('article');
      var toolbar = root.querySelector('[data-st-toolbar]');
      var counter = root.querySelector('[data-highlights-count]');
      var countLabel = toolbar.querySelector('[data-st-count]');
      var live = root.querySelector('[data-st-live]');
      var debounce = null;
      var visible = false;
      var currentText = '';

      function announce(message) {
        live.textContent = message;
      }

      function updateCounter() {
        var marks = article.querySelectorAll('mark').length;
        var label = marks + (marks === 1 ? ' highlight' : ' highlights');
        counter.textContent = label;
        counter.setAttribute('aria-label', label);
      }

      function selectionInArticle() {
        var sel = window.getSelection();
        if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return null;
        var range = sel.getRangeAt(0);
        var node = range.commonAncestorContainer;
        if (node !== article && !article.contains(node)) return null;
        var text = sel.toString().trim();
        if (!text) return null;
        return { range: range, text: text };
      }

      function showToolbar() {
        var snap = selectionInArticle();
        if (!snap) { hideToolbar(); return; }
        var rect = snap.range.getBoundingClientRect();
        if (rect.width === 0) { hideToolbar(); return; }
        toolbar.classList.remove('hidden');
        toolbar.classList.add('flex');
        visible = true;
        currentText = snap.text;
        countLabel.textContent = snap.text.length + (snap.text.length === 1 ? ' char' : ' chars');
        var w = toolbar.offsetWidth;
        var h = toolbar.offsetHeight;
        var left = rect.left + rect.width / 2 - w / 2;
        left = Math.min(Math.max(left, 8), window.innerWidth - w - 8);
        var top = rect.top - h - 12;
        if (top < 8) top = rect.bottom + 12;
        toolbar.style.left = left + 'px';
        toolbar.style.top = top + 'px';
      }

      function hideToolbar() {
        toolbar.classList.add('hidden');
        toolbar.classList.remove('flex');
        visible = false;
      }

      function refresh() {
        window.clearTimeout(debounce);
        debounce = window.setTimeout(showToolbar, 120);
      }

      document.addEventListener('selectionchange', refresh);

      window.addEventListener('scroll', function () {
        if (visible) { refresh(); }
      }, { passive: true });

      window.addEventListener('resize', function () {
        if (visible) { showToolbar(); }
      });

      document.addEventListener('pointerdown', function (event) {
        if (!visible || root.contains(event.target)) return;
        hideToolbar();
      });

      document.addEventListener('keydown', function (event) {
        if (!visible || event.key !== 'Escape') return;
        hideToolbar();
        event.preventDefault();
        event.stopPropagation();
      }, true);

      // Keep the selection alive across toolbar interaction.
      toolbar.addEventListener('mousedown', function (event) {
        event.preventDefault();
      });

      function copyText(text) {
        if (navigator.clipboard && navigator.clipboard.writeText) {
          try {
            return navigator.clipboard.writeText(text).then(function () { return true; });
          } catch (e) {
            // Denied clipboard permissions fall through to execCommand below.
          }
        }
        var helper = document.createElement('textarea');
        helper.value = text;
        helper.setAttribute('readonly', '');
        helper.style.position = 'absolute';
        helper.style.left = '-9999px';
        document.body.appendChild(helper);
        helper.select();
        var ok = false;
        try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
        document.body.removeChild(helper);
        return Promise.resolve(ok);
      }

      function feedback(span, doneLabel) {
        var original = span.textContent;
        span.textContent = doneLabel;
        window.setTimeout(function () { span.textContent = original; }, 1400);
      }

      toolbar.querySelector('[data-st-copy]').addEventListener('click', function () {
        var span = toolbar.querySelector('[data-st-copy-label]');
        copyText(currentText).then(function (ok) {
          if (ok) {
            feedback(span, 'Copied');
            announce('Copied to clipboard');
          } else {
            announce('Copy failed');
          }
        });
      });

      toolbar.querySelector('[data-st-share]').addEventListener('click', function () {
        var span = toolbar.querySelector('[data-st-share-label]');
        var title = root.querySelector('h2') ? root.querySelector('h2').textContent.trim() : 'this article';
        var payload = '“' + currentText + '” — ' + title;
        copyText(payload).then(function (ok) {
          if (ok) {
            feedback(span, 'Copied');
            announce('Share quote copied');
          } else {
            announce('Share failed');
          }
        });
      });

      toolbar.querySelector('[data-st-highlight]').addEventListener('click', function () {
        var snap = selectionInArticle();
        if (!snap) return;
        var mark = document.createElement('mark');
        mark.className = 'bg-[#FFE600] px-0.5 font-medium text-black';
        try {
          snap.range.surroundContents(mark);
        } catch (e) {
          mark.appendChild(snap.range.extractContents());
          snap.range.insertNode(mark);
        }
        window.getSelection().removeAllRanges();
        updateCounter();
        announce('Highlighted passage');
        hideToolbar();
      });

      updateCounter();
    })();
  </script>
</div>
268 lines UTF-8 · LF · Spaces: 2

AI-ready

Available to your agent.

This component is reachable through the DevSnips CLI, MCP server and Skills integrations — one registry, one resource path.

Resource path

devsnips://components/tooltips/selection-toolbar

Continue browsing

View all