Recently Second Street launched a feature in its email campaign editor that made it much easier to use mail merge style tokens. Rather than seeing {{User.FirstName}} in the input for the subject line, users will now see User First Name in the input, but highlighted so it appears visually different to show that it's a token.

But of course, you can't really style parts of an <input>'s value differently. We could have solved this problem by moving to contenteditable, but that's quite a bit of work when all you really need is a clever bit of CSS hackery to get the feature working.

Here's the final version of the feature. I'll walk you through building it from scratch, explaining my choices along the way.

See the Pen Highlight Blog Post 1 by Kerrick Long (@Kerrick) on CodePen.

HTML & CSS

The basic strategy of the user interface is to render the text twice: once in an <input> element that the user can edit as normal, and once behind that <input> element (perfectly aligned) so that we can add backgrounds and borders to certain words.

<div class="with-highlight">  
  <input class="with-highlight__input" value="My content has token values">
  <div class="with-highlight__echo" aria-hidden="true">My content has <span class="with-highlight__highlight">token</span> values</div>
</div>  

We use BEM here at Second Street, so let's create a new Block, with-highlight. Its Elements will be the input, an echo to render the same text as the input, and some highlights to style the tokens themselves.

Use aria-hidden to make sure that even though the text is in the DOM twice, it doesn't show up twice to screen readers and other a11y tools.

.with-highlight {
  position: relative;
  overflow: hidden;
  font: 14px sans-serif;
  border: 1px solid lightgrey;
  display: block;
}

In order to position an element behind another, we'll use absolute positioning. That means our containing element, .with-highlight, needs to have position: relative.

Because the <input> itself is going to have no background or border so the elements underneath are visible, we're going to make our .with-highlight element look like an input instead. Also, we set overflow: auto; to cut off our echo text so highlighted words aren't visible outside the fake input.

.with-highlight__input {
  background: none;
  border: none;
  font: inherit;
  display: block;
  padding: 2px;
  width: 100%;
}

The input itself needs to be completely see-through, so we take away its background and border. Make a note of the top and left padding values you use here, because you'll need them later.

We're setting font: inherit because we want to make sure the font-size, font-family, and such are the same for the input and the echo; most browsers have different default fonts for <input>s than for <div>s.

.with-highlight__echo {
  position: absolute;
  top: 2px;
  left: 2px;
  color: transparent;
  z-index: -1;
  white-space: pre;
}

In order to prevent strange things happening with subpixel rendering, we're going to make our echo text have color:transparent -- we only need the text to make sure everything is sized correctly anyways.

Here's where you'll need the padding values you used on the input: the top and left of the echo text should be the same number of pixels, to make sure the text lines up.

We are setting z-index to a negative value so that even though the it has position: absolute it still falls behind the input.

And finally, white-space: pre ensures that multiple whitespace characters in a row in the <input> value don't collapse into one in the echo.

.with-highlight__highlight {
  background: #ffc6f1;
  border-radius: 2px;
  box-shadow: 0 0 0 1px #ff2ecc;
}

The reason we're using box-shadow here is pretty interesting. We can't use border because it would add space, pushing the letters inside and after the highlight further to the right. We can't use outline because it won't respect border-radius. But we can use a box-shadow with no offset and no blur, just a spread radius, to approximate a border without taking up any space.

Now you've got a working(-ish) version of the feature!

See the Pen Highlight Blog Post HTML and CSS by Kerrick Long (@Kerrick) on CodePen.

If you spend any time typing in the input, you'll notice a few problems, which we'll solve next.

  • When you type into the input, the echo text doesn't update.
  • If the input's value is longer than what fits into the input, the highlights get out-of-sync.

Re-rendering the echo text when the input's value changes

class InputHighlighter {  
  // ...

  renderEcho = () => {
    let newEchoHTML = this.input.value;
    this.tokens.forEach(
      token =>
        (newEchoHTML = newEchoHTML.replace(
          new RegExp(`(${token})`, 'g'),
          '<span class="with-highlight__highlight">$1</span>'
        ))
    );
    this.echo.innerHTML = newEchoHTML;
    this.syncEcho();
  };
}

To re-render the echo text, we need to grab the value of the input element, and wrap each token with the highlight element.

class InputHighlighter {  
  // ...

  setupListeners() {
    this.input.addEventListener('input', this.renderEcho);
    // ...
  }
}

We need to make sure that when the input's value changes, we re-render the echo text so it stays in sync.

Syncing the echo text with the input scroll position

class InputHighlighter {  
  // ...

  syncEcho = () => {
    const { paddingLeft } = window.getComputedStyle(this.input);
    const distance = -1 * ((this.input.scrollLeft || 0) - parseFloat(paddingLeft));
    this.echo.style.left = `${distance}px`;
  };
}

Input elements have a scrollLeft property that we can use to read how far "out of view" the text is. Just like in the CSS, we need to be sure to take our input's padding into account!

class InputHighlighter {  
  // ...

  setupListeners() {
    // ...
    document.addEventListener('selectionchange', this.syncEcho);
  }
}

The DOM provides us a really convenient event that seems to catch every instance where the input's scrollLeft property could change without the text re-rendering: selectionchange. We'll listen to this event, and run the echo syncing code each time it fires.

Tying it all together

class InputHighlighter {  
  // ...

  constructor(element, tokens = []) {
    this.element = element;
    this.tokens = tokens;
    this.validateDOM();
    this.setupListeners();
    this.renderEcho();
  }

  destroy() {
    this.element.removeChild(this.echo);
    this.input.removeEventListener('input', this.renderEcho);
    document.removeEventListener('selectionchange', this.syncEcho);
  }

  validateDOM() {
    if (!this.element) {
      throw new Error('Cannot create an InputHighlighter without an element.');
    }
    if (this.element.querySelector('input.with-highlight__input')) {
      this.input = this.element.querySelector('input.with-highlight__input');
    } else {
      throw new Error('Cannot create an InputHighlighter without an <input class="with-highlight__input">');
    }
    if (this.element.querySelector('div.with-highlight__echo')) {
      this.echo = this.element.querySelector('div.with-highlight__echo');
    } else {
      const echo = document.createElement('div');
      echo.classList.add('with-highlight__echo');
      this.element.appendChild(echo);
      this.echo = echo;
      this.renderEcho();
    }
  }
}

function highlightTokens(selector = 'input', tokens) {  
  document.querySelectorAll(selector).forEach(element => new InputHighlighter(element, tokens));
}

highlightTokens('.with-highlight', ['token', 'hello', 'world']);  

A bit more code provides us with a nice constructor function that sets up the object's properties and the DOM, a destroy method that cleans up old event listeners, and some validation for defensive programming.

Here it is in action once more:

See the Pen Highlight Blog Post 1 by Kerrick Long (@Kerrick) on CodePen.

If you thought this was really neat, please share this article on Twitter. And if you'd like to work on cool problems like this every day, consider working for Second Street!