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
9:41 100%
devsnips.dev/library/tailwind/components/tooltips/selection-toolbar/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add Tailwind/Components/Tooltips/selection-toolbar Tailwind/Components/Tooltips/selection-toolbar <!--
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> # Selection Toolbar
Selection toolbar with tooltip-like presentation.
## Usage
```bash
npx devsnips add Tailwind/Components/Tooltips/selection-toolbar
```
Copy from `code.html`.
## File Structure
`code.html` · `preview.html` · `metadata.json` · `README.md` 268 lines UTF-8 · LF · Spaces: 2
Continue browsing