Smart Tag Autocomplete for Notes

January 5, 2026

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/Tab to select, Esc to 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:

  1. Paste the script below anywhere on your page (end of <body> is fine).
  2. Add data-tagbox to your textarea (or pass it manually).
  3. 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.

← Back to all scripts