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:
- Using a
background-image
(TinyMCE does that, using a PNG) - 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.
Let's dive in.
JavaScript
First, the main method:
lineNumbers(element, numLines = 50, inline = false)
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-';
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;
}
}
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);
We need a random id for our property as well:
const id = `${prefix}${Math.random().toString(36).substr(2, 6)}`;
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>`;
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)}")`;
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})`;
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.
Top comments (10)
Very cool!
I looked at making the numbers dynamic and redrawing on textarea input. I did a vary non-scientific stress test and starting getting major issues around line 5000 😅.
Interesting! 😊 I guess there must be a tiny variation in how browsers calculate line-height for textarea and svg?
Sorry I should have specified, the issues were with performance, the slow down while remaking the image for each new line.
Ah, ok!
Cool, I am going to use this in a styled textarea
FYI, modern browsers can do:
const id = crypto.randomUUID()
Ah, true! I used
crypto
on another project, forgot it on this 😉Very nice solution!
I just noticed that it does not work proper with firefox, the scrolling does not work...
And yes I know the market share is sadly not much but I just wanted to let you know ;)
Thanks! It looks fine in Firefox Nightly on my Mac - when I get more time I’ll look into better browser-support.
Thanks for sharing. I like it.
Thanks!