The other day I was working on a JSON Schema Generator, and wanted to display line numbers in a <textarea>. Nothing fancy, and not considering soft line-breaks or anything remotely complicated.

I did some research, and found multiple approaches:

  1. Using a background-image (TinyMCE does that, using a PNG)
  2. Using an <ol> ordered list.

I did not like any of them! The first one didn't look crisp — and didn't match the styles I already had in place for my <textarea>-elements.

The second one required a bunch of JavaScript to maintain that ordered list: adding/removing <li>-elements dynamically, syncing scroll-events and much more.

So I ended up creating a hybrid.

It's a dynamically generated SVG, stored as a CSS Custom Property — and used as a background-image, inheriting the styles from it's parent <textarea>-element.

Line Numbers

Let's dive in.


JavaScript

First, the main method:

lineNumbers(element, numLines = 50, inline = false)
Enter fullscreen mode Exit fullscreen mode

element is the <textarea> element to use, numLines the number of lines to render, and inline indicates whether to store the generated image on element (true), or on document.body (false).

Next, we define a prefix for the custom property:

const prefix = '--linenum-';
Enter fullscreen mode Exit fullscreen mode

Before we continue, we check whether to re-use any existing property:

if (!inline) {
  const styleString = document.body.getAttribute('style') || '';
  const regex = new RegExp(`${prefix}[^:]*`, 'g');
  const match = styleString.match(regex);

  if (match) {
    element.style.backgroundImage = `var(${match[0]})`;
    return;
  }
}
Enter fullscreen mode Exit fullscreen mode

Next, we extract styles from element, rendering the SVG with the same font-family, font-size, line-height etc. :

const bgColor = getComputedStyle(element).borderColor;
const fillColor = getComputedStyle(element).color;
const fontFamily = getComputedStyle(element).fontFamily;
const fontSize = parseFloat(getComputedStyle(element).fontSize);
const lineHeight = parseFloat(getComputedStyle(element).lineHeight) / fontSize;
const paddingTop = parseFloat(getComputedStyle(element).paddingTop) / 2;
const translateY = (fontSize * lineHeight).toFixed(2);
Enter fullscreen mode Exit fullscreen mode

We need a random id for our property as well:

const id = `${prefix}${Math.random().toString(36).substr(2, 6)}`;
Enter fullscreen mode Exit fullscreen mode

And now it's time to render the SVG:

const svg = `<svg xmlns="http://www.w3.org/2000/svg">
  <style>
    svg { background: ${bgColor}; }
    text {
      fill: hsl(from ${fillColor} h s l / 50%);
      font-family: ${fontFamily};
      font-size: ${fontSize}px;
      line-height: ${lineHeight};
      text-anchor: end;
      translate: 0 calc((var(--n) * ${translateY}px) + ${paddingTop}px);
    }
  </style>
  ${Array.from({ length: numLines }, (_, i) => `<text x="90%" style="--n:${i + 1};">${i + 1}</text>`).join("")}
</svg>`;
Enter fullscreen mode Exit fullscreen mode

Let's break it down:

In the <style>-section we simply set the styles we extracted from the <textarea> earlier. Instead of using y and dy attributes for the <text>-elements, we simply use a --n-property to translate the text-elements using CSS.

The last part iterates an array created from numLines, and appends the <text>-elements to the main SVG.

We're almost there!


To use the generated SVG as a url()-property, we need to encode it:

const encodedURI = `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
Enter fullscreen mode Exit fullscreen mode

And finally, we set that property on either element or document-body:

const target = inline ? element : document.body;
target.style.setProperty(id, encodedURI);
element.style.backgroundImage = `var(${id})`;
Enter fullscreen mode Exit fullscreen mode

And that's it!

Not too bad, and only 610 bytes, minified and compressed!


Demo

You can see a demo here, and dowload the full script here.

Below is a simplified Codepen, not using the inline-property logic:


Pros and Cons

Are there pros and cons? Of course there are!

Personally — for my current project — I needed a simple, crisp way of adding line numbers to a JSON-preview within a <textarea>, and this method fits the bill.

Pros

Reduced DOM Manipulation

This method does not rely on manipulating the DOM. The line numbers are generated as a single SVG, stored within a CSS Custom Property.

Automatic Synchronization

Since the line numbers are part of the background image, they automatically scroll with the text content, eliminating the need for manual synchronization logic.

Reusability Across Elements

By storing the generated SVG in a CSS Custom Property, it can be reused across multiple elements. This means that if several elements require the same line numbers, they can all reference the same custom property, avoiding redundant SVG generation.

Scalability

The vector nature of SVG ensures that the line numbers remain crisp and clear at any zoom level.

Cons

Accessibility

Ordered lists are more accessible to screen readers and assistive technologies, while SVG-based line numbers might be ignored or misinterpreted.

Customization Complexity

Styling and interacting with individual line numbers in an ordered list is straightforward. In contrast, the SVG approach makes it more difficult to customize or add interactivity to specific line numbers.

Browser Compatibility

SVG and CSS custom properties might not render consistently across all browsers — the current implementation has issues with Safari, where we need to deduct (paddingTop / 10) from translateY.

Dynamic Content Handling

Ordered lists can be more flexible for handling dynamic content updates, such as adding or removing lines, whereas the SVG approach might require regenerating and reapplying the entire background image.