<main>
  <header>
    <h1>Caret position pop-ups</h1>
  </header>
  <article>
    <p>
      <strong>
        Grab the caret position of an <code>input</code> element. Then position a pop-up based on that. Handy for UI patterns seen when editing text or for tagging.
      </strong>
    </p>
    <section>
      <h2>Track caret</h2>
      <p>Display a pop-up where the caret is.</p>
      <textarea name="basic" id="basic" placeholder="Interact with me!" spellcheck="false"></textarea>
    </section>
    <section>
      <h2>On selection</h2>
      <p>Display a pop-up where text is selected.</p>
      <textarea name="select" id="select" spellcheck="false">
Lorem ipsum dolor sit amet consectetur adipisicing, elit. Quas ratione nesciunt nulla perspiciatis voluptate expedita praesentium modi quaerat exercitationem voluptatibus dolorem ducimus sunt, fugiat assumenda velit dolorum eum, ipsum aperiam.
          </textarea>
    </section>
    <section>
      <h2>Character trigger</h2>
      <p>Use a text trigger to show a pop-up.</p>
      <textarea name="trigger" id="trigger" placeholder="Type the @ symbol to trigger" spellcheck="false"></textarea>
    </section>
  </article>
</main>
<div class="caret-marker" popover id="caret-marker"></div>
@layer demo {
  [popover] {
    margin: 0;
    background: var(--text-1);
    padding: var(--size-2);
    color: var(--surface-1);
    top: calc(var(--y) * 1px);
    left: calc(var(--x) * 1px);
    border-radius: var(--radius-2);
    transition: all 0.1s ease;
  }

  .popup--basic {
    transform: translate(10%, -10%);
  }
  .popup--select {
    transform: translate(-50%, -125%);
  }
  .popup--trigger {
    transform: translate(5%, -110%);
  }
}

@layer base {
  *,
  *:after,
  *:before {
    box-sizing: border-box;
  }

  body {
    min-height: 100vh;
    display: block;
    overflow: auto;
    background: var(--surface-2);
    font-family: "Google Sans", sans-serif, system-ui;
  }

  :where([popover]) {
    margin: auto;
    border-width: 0;
    border-style: none;
    background: transparent;
  }

  h1 {
    margin: 0;
    color: var(--gray-0);
  }

  header {
    height: 15vmin;
    min-height: 200px;
    background: var(--gradient-16);
    display: grid;
    place-items: center;
    color: var(--gray-0);
    padding: var(--size-4);
  }

  h1 {
    background: var(--gradient-19);
    background-clip: text;
    -webkit-background-clip: text;
    background-attachment: fixed;
    color: transparent;
    font-size: var(--font-size-fluid-2);
    text-align: center;
  }

  h2 {
    font-size: var(--font-size-fluid-1);
  }

  main {
    margin: 0 auto;
  }

  textarea {
    width: 100%;
    background: var(--surface-1);
    border-radius: var(--radius-2);
    border: 2px solid var(--text-2);
    min-height: 120px;
  }

  article {
    padding: var(--size-4);
    display: flex;
    flex-direction: column;
    margin: 0 auto;
    width: var(--size-content-3);
    max-width: 100%;
    padding: var(--size-4);
  }

  article > * + * {
    margin-top: var(--size-4);
  }

  section > * + * {
    margin-top: var(--size-2);
  }
}
/**
 * Utility methods from https://github.com/jh3y/use-caret-position
 * */
const getCaretPosition = (input, selection = "selectionStart") => {
  const { scrollLeft, scrollTop } = input;
  // This provides a hook for getSelection to reuse getCaretPosition.
  const selectionPoint = input[selection] || input.selectionStart;
  const { height, width, left, top } = input.getBoundingClientRect();
  // create a dummy element that will be a clone of our input
  const div = document.createElement("div");
  // get the computed style of the input and clone it onto the dummy element
  const copyStyle = getComputedStyle(input);
  for (const prop of copyStyle) {
    div.style[prop] = copyStyle[prop];
  }
  // we need a character that will replace whitespace when filling our dummy element if it's a single line <input/>
  const swap = ".";
  const inputValue =
    input.tagName === "INPUT" ? input.value.replace(/ /g, swap) : input.value;
  // set the div content to that of the textarea up until selection
  const textContent = inputValue.substr(0, selectionPoint);
  // set the text content of the dummy element div
  div.textContent = textContent;
  if (input.tagName === "TEXTAREA") div.style.height = "auto";
  // if a single line input then the div needs to be single line and not break out like a text area
  if (input.tagName === "INPUT") div.style.width = "auto";
  // Apply absolute positioning to account for textarea resize, etc.
  div.style.position = "absolute";
  // create a marker element to obtain caret position
  const span = document.createElement("span");
  // give the span the textContent of remaining content so that the recreated dummy element is as close as possible
  span.textContent = inputValue.substr(selectionPoint) || ".";
  // append the span marker to the div
  div.appendChild(span);
  // append the dummy element to the body
  document.body.appendChild(div);
  // get the marker position, this is the caret position top and left relative to the input
  const { offsetLeft: spanX, offsetTop: spanY } = span;
  // lastly, remove that dummy element
  // NOTE:: can comment this out for debugging purposes if you want to see where that span is rendered
  document.body.removeChild(div);
  // return an object with the x and y of the caret. account for input positioning so that you don't need to wrap the input
  let x = left + spanX;
  let y = top + spanY;
  const { lineHeight, paddingRight } = copyStyle;
  x = Math.min(x - scrollLeft, left + width - parseInt(paddingRight, 10));
  // Need to account for any scroll position for the window.
  y =
    Math.min(y - scrollTop, top + height - parseInt(lineHeight, 10)) +
    window.scrollY;
  return {
    x,
    y
  };
};

/**
 * Returns the position given an inputs current selection
 * */
const getSelectionPosition = (input) => {
  const { y: startY, x: startX } = getCaretPosition(input, "selectionStart");
  const { x: endX } = getCaretPosition(input, "selectionEnd");
  // Gives you a basic left position for where to put it and the starting position.
  const x = startX + (endX - startX) / 2;
  const y = startY;
  return {
    x,
    y
  };
};
/** End utility methods */

const BASIC = document.querySelector("#basic");
const SELECT = document.querySelector("#select");
const TRIGGER = document.querySelector("#trigger");
const POPUP = document.querySelector("[popover]");
const EVENTS = ["keyup", "click"];

const updatePopUp = ({ x, y }, html, mod) => {
  if (!POPUP.matches(":open")) POPUP.showPopover();
  POPUP.innerHTML = html;
  POPUP.className = `popup popup--${mod}`;
  POPUP.style = `
    --x: ${x};
    --y: ${y - window.scrollY};
  `;
};
EVENTS.forEach((eventType) => {
  BASIC.addEventListener(eventType, () =>
    updatePopUp(getCaretPosition(BASIC), "👈 Here!", "basic")
  );
});

SELECT.addEventListener("select", () => {
  if (SELECT.selectionStart !== SELECT.selectionEnd)
    updatePopUp(getSelectionPosition(SELECT), "Here! 👇", "select");
});
TRIGGER.addEventListener("input", () => {
  const previousCharacter = TRIGGER.value
    .charAt(TRIGGER.selectionStart - 2)
    .trim();
  const character = TRIGGER.value.charAt(TRIGGER.selectionStart - 1).trim();
  if (character === "@" && previousCharacter === "") {
    updatePopUp(getCaretPosition(TRIGGER), "Special! ✨", "trigger");
  }
  if (character === "" && POPUP.matches(":open")) {
    POPUP.hidePopover();
  }
});

External CSS

  1. https://codepen.io/web-dot-dev/pen/XWqWYgB.css
  2. https://codepen.io/web-dot-dev/pen/ZExZWBQ.css

External JavaScript

  1. https://codepen.io/web-dot-dev/pen/XWqWYgB.js
  2. https://codepen.io/web-dot-dev/pen/ZExZWBQ.js