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

What it does

Capabilities

  • Floating toolbar above selected text
  • Highlight wraps the range in a real <mark>
  • Copy and Share with clipboard fallback
  • Screen-reader status via aria-live region

Preview

Live component

Interactive preview
Open preview
9:41 100%
devsnips.site/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
DevSnips AI ready Claude · Cursor · Windsurf

Agent skill & Model Context Protocol

Pass this component straight into your AI workflow. DevSnips exposes each resource as a structured, addressable registry entry, so your agent installs the real files instead of approximating them.

devsnips://components/tooltips/selection-toolbar
Prompt instruction
I want to use this DevSnips resource in production:

Page:
https://devsnips.site/library/tailwind/components/tooltips/selection-toolbar/

Install: npx devsnips add
Tailwind/Components/Tooltips/selection-toolbar

This is the 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.

Use this DevSnips resource as the starting point for my project.
Adapt it to my project's stack and conventions, fix any issues,
and make it production-ready while preserving its intended design
and behavior.

Paste into ChatGPT, Claude or your agent — it already carries the page URL and the exact install path.

Resource URI
devsnips://components/tooltips/selection-toolbar