Smart Tag Autocomplete for Notes
Lightweight drop-in script that adds a modern “#tag autocomplete” experience to any note-taking app (or any plain textarea/contenteditable).
What it does:
- Detects when the user types
#and starts suggesting tags as they type. - Keyboard friendly:
↑/↓to move,Enter/Tabto select,Escto close. - Auto-inserts the chosen tag and keeps typing smooth.
- Includes a tiny helper to extract and normalize tags from the note text.
- No frameworks, no build step, no dependencies.
Install:
- Paste the script below anywhere on your page (end of
<body>is fine). - Add
data-tagboxto your textarea (or pass it manually). - Provide an array of tags (static list, or fetched from your backend).
Example:
<textarea data-tagbox placeholder="Write a note..."></textarea>- Start typing:
#mee→ suggests#meetings,#metrics, etc.
<script>
(function(){
// ==========================
// Tiny Tag Autocomplete
// ==========================
function clamp(n,min,max){ return Math.max(min, Math.min(max,n)); }
function uniq(arr){
var seen = Object.create(null), out = [];
for (var i=0;i<arr.length;i++){
var k = String(arr[i]).toLowerCase();
if (!k || seen[k]) continue;
seen[k]=1; out.push(String(arr[i]));
}
return out;
}
function extractTags(text){
// returns normalized tags without '#', lowercase
var m = String(text||'').match(/(^|\s)#([a-z0-9_/-]{1,32})/ig) || [];
var tags = m.map(function(x){ return x.replace(/^.*#/, '').toLowerCase(); });
return uniq(tags);
}
function createMenu(){
var box = document.createElement('div');
box.style.position = 'absolute';
box.style.zIndex = '9999';
box.style.minWidth = '160px';
box.style.maxWidth = '320px';
box.style.background = '#0f172a';
box.style.color = '#e5e7eb';
box.style.border = '1px solid rgba(255,255,255,.12)';
box.style.borderRadius = '12px';
box.style.boxShadow = '0 18px 50px rgba(0,0,0,.45)';
box.style.padding = '6px';
box.style.fontFamily = 'system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif';
box.style.fontSize = '14px';
box.style.display = 'none';
return box;
}
function setMenuItems(menu, items, activeIndex){
menu.innerHTML = '';
for (var i=0;i<items.length;i++){
var it = document.createElement('div');
it.textContent = '#' + items[i];
it.setAttribute('data-i', String(i));
it.style.padding = '8px 10px';
it.style.borderRadius = '10px';
it.style.cursor = 'pointer';
it.style.userSelect = 'none';
if (i === activeIndex){
it.style.background = 'rgba(59,130,246,.22)';
it.style.border = '1px solid rgba(59,130,246,.35)';
} else {
it.style.background = 'transparent';
it.style.border = '1px solid transparent';
}
menu.appendChild(it);
}
}
function caretCoordsForTextarea(el){
// Minimal caret position approximation for textarea:
// Use a hidden mirror div (works well enough for dropdown placement).
var style = getComputedStyle(el);
var div = document.createElement('div');
var props = [
'boxSizing','width','height','overflowX','overflowY',
'borderTopWidth','borderRightWidth','borderBottomWidth','borderLeftWidth',
'paddingTop','paddingRight','paddingBottom','paddingLeft',
'fontStyle','fontVariant','fontWeight','fontStretch','fontSize','fontSizeAdjust',
'lineHeight','fontFamily','textAlign','textTransform','textIndent','textDecoration',
'letterSpacing','wordSpacing','tabSize','MozTabSize'
];
div.style.position = 'absolute';
div.style.visibility = 'hidden';
div.style.whiteSpace = 'pre-wrap';
div.style.wordWrap = 'break-word';
div.style.left = '-9999px';
props.forEach(function(p){ div.style[p] = style[p]; });
var value = el.value;
var caret = el.selectionStart || 0;
div.textContent = value.substring(0, caret);
var span = document.createElement('span');
span.textContent = value.substring(caret) || '.';
div.appendChild(span);
document.body.appendChild(div);
var r = span.getBoundingClientRect();
var d = div.getBoundingClientRect();
document.body.removeChild(div);
// coords relative to viewport
return {
left: r.left,
top: r.top,
height: r.height || parseFloat(style.lineHeight) || 16
};
}
function findActiveTagQuery(text, caretPos){
// returns {start,end,query} or null; matches "#word" right before caret
var left = text.slice(0, caretPos);
// allow start or whitespace before #
var m = left.match(/(^|\s)#([a-z0-9_/-]{0,32})$/i);
if (!m) return null;
var query = m[2] || '';
var start = left.lastIndexOf('#' + query);
if (start < 0) return null;
return { start: start, end: caretPos, query: query.toLowerCase() };
}
function insertTag(el, range, tag){
var v = el.value;
var before = v.slice(0, range.start);
var after = v.slice(range.end);
var insert = '#' + tag + ' ';
el.value = before + insert + after;
var pos = (before + insert).length;
el.setSelectionRange(pos, pos);
el.dispatchEvent(new Event('input', { bubbles:true }));
}
function attachTagAutocomplete(textarea, tags){
tags = uniq(tags || []);
var menu = createMenu();
document.body.appendChild(menu);
var state = {
open:false,
items:[],
active:0,
range:null
};
function close(){
state.open = false;
state.items = [];
state.active = 0;
state.range = null;
menu.style.display = 'none';
}
function openAtCaret(){
var rect = textarea.getBoundingClientRect();
var caret = caretCoordsForTextarea(textarea);
// fallback positioning near textarea if mirror is off
var left = clamp(caret.left, rect.left + 8, rect.right - 8);
var top = clamp(caret.top + caret.height + 6, rect.top + 8, rect.bottom + 200);
menu.style.left = (left + window.scrollX) + 'px';
menu.style.top = (top + window.scrollY) + 'px';
menu.style.display = 'block';
}
function update(){
var q = findActiveTagQuery(textarea.value, textarea.selectionStart || 0);
if (!q) return close();
state.range = q;
var query = q.query;
var matches = tags
.map(function(t){ return String(t).replace(/^#/, ''); })
.filter(function(t){
if (!query) return true;
return t.toLowerCase().indexOf(query) === 0;
})
.slice(0, 8);
if (!matches.length) return close();
state.items = matches;
state.active = clamp(state.active, 0, matches.length - 1);
state.open = true;
setMenuItems(menu, matches, state.active);
openAtCaret();
}
textarea.addEventListener('input', update);
textarea.addEventListener('click', update);
textarea.addEventListener('scroll', function(){ if (state.open) openAtCaret(); });
textarea.addEventListener('keydown', function(e){
if (!state.open) return;
if (e.key === 'ArrowDown'){
e.preventDefault();
state.active = clamp(state.active + 1, 0, state.items.length - 1);
setMenuItems(menu, state.items, state.active);
return;
}
if (e.key === 'ArrowUp'){
e.preventDefault();
state.active = clamp(state.active - 1, 0, state.items.length - 1);
setMenuItems(menu, state.items, state.active);
return;
}
if (e.key === 'Escape'){
e.preventDefault();
close();
return;
}
if (e.key === 'Enter' || e.key === 'Tab'){
e.preventDefault();
if (state.range && state.items[state.active]){
insertTag(textarea, state.range, state.items[state.active]);
close();
}
return;
}
});
menu.addEventListener('mousedown', function(e){
var t = e.target;
if (!(t instanceof HTMLElement)) return;
var i = t.getAttribute('data-i');
if (i === null) return;
e.preventDefault(); // keep focus in textarea
var idx = parseInt(i, 10);
if (!isFinite(idx)) return;
if (state.range && state.items[idx]){
insertTag(textarea, state.range, state.items[idx]);
close();
}
});
document.addEventListener('click', function(e){
if (e.target === textarea) return;
if (menu.contains(e.target)) return;
close();
});
// expose tag extractor
textarea.extractTags = function(){ return extractTags(textarea.value); };
}
// ==========================
// Auto-init
// ==========================
document.addEventListener('DOMContentLoaded', function(){
var el = document.querySelector('textarea[data-tagbox]');
if (!el) return;
// Replace with your tags (static list, or fetched then attach)
var tags = ['ideas','meetings','todo','journal','project-x','reading','health','bugs','notes'];
attachTagAutocomplete(el, tags);
// Example: log tags on save
// console.log(el.extractTags());
});
})();
</script>
Comments (0)
No comments yet — be the first.