When I was working on a project that needed an editor component for source code, I really wanted a way to have that editor highlight the syntax that is typed. There are projects like this, like CodeMirror, Ace, and Monaco, but they are all heavy-weight, full-featured editors, not just editable textareas
with syntax highlighting like I wanted.
It took a little finagling, but I wound up making something that does the job and wanted to share how I did it, because it involves integrating a popular syntax highlighting library with HTML’s editing capabilities, as well as a few interesting edge cases to take into consideration.
Go ahead and give it a spin as we dig in!
After a suggestion, I have also released this as a custom element on GitHub, so you can quickly use the component in a webpage as a single <code-input>
element.
The problem
First, I tried using the contenteditable
attribute on a div. I typed some source code into the div and ran it through Prism.js, a popular syntax highlighter, on oninput
via JavaScript. Seems like a decent idea, right? We have an element that can be edited on the front end, and Prism.js applies its syntax styling to what’s typed in the element.
Chris covers how to use Prism.js in this video.
But that was a no-go. Each time the content in the element changes, the DOM is manipulated and the user’s cursor is pushed back to the start of the code, meaning the source code appears backwards, with the last characters at the start, and the first characters at the end.
Next, I tried about using a <textarea>
but that also didn’t work, as textareas can only contain plain text. In other words, we’re unable to style the content that’s entered. A textarea
seems to be the only way to edit the text without unwanted bugs — it just doesn’t let Prism.js do its thing.
Prism.js works a lot better when the source code is wrapped in a typical <pre><code>
tag combo — it’s only missing the editable part of the equation.
So, neither seems to work by themselves. But, I thought, why not both?
The solution
I added both a syntax-highlighted <pre><code>
and a textarea
to the page, and made the innerText
content of <pre><code>
change oninput
, using a JavaScript function. I also added an aria-hidden
attribute to the <pre><code>
result so that screen readers would only read what is entered into the <textarea>
instead of being read aloud twice.
<textarea id="editing" oninput="update(this.value);"></textarea>
<pre id="highlighting" aria-hidden="true">
<code class="language-html" id="highlighting-content"></code>
</pre>
function update(text) {
let result_element = document.querySelector("#highlighting-content");
// Update code
result_element.innerText = text;
// Syntax Highlight
Prism.highlightElement(result_element);
}
Now, when the textarea
is edited — as in, a pressed key on the keyboard comes back up — the syntax-highlighted code changes. There are a few bugs we’ll get to, but I want to focus first on making it look like you are directly editing the syntax-highlighted element, rather than a separate textarea
.
Making it “feel” like a code editor
The idea is to visibly merge the elements together so it looks like we’re interacting with one element when there are actually two elements at work. We can add some CSS that basically allows the <textarea>
and the <pre><code>
elements to be sized and spaced consistently.
#editing, #highlighting {
/* Both elements need the same text and space styling so they are directly on top of each other */
margin: 10px;
padding: 10px;
border: 0;
width: calc(100% - 32px);
height: 150px;
}
#editing, #highlighting, #highlighting * {
/* Also add text styles to highlighting tokens */
font-size: 15pt;
font-family: monospace;
line-height: 20pt;
}
Then we want to position them right on top of each other:
#editing, #highlighting {
position: absolute;
top: 0;
left: 0;
}
From there, z-index
allows the textarea
to stack in front the highlighted result:
/* Move the textarea in front of the result */
#editing {
z-index: 1;
}
#highlighting {
z-index: 0;
}
If we stop here, we’ll see our first bug. It doesn’t look like Prism.js is highlighting the syntax, but that is only because the textarea
is covering up the result.
We can fix this with CSS! We’ll make the <textarea>
completely transparent except the caret (cursor):
/* Make textarea almost completely transparent */
#editing {
color: transparent;
background: transparent;
caret-color: white; /* Or choose your favorite color */
}
Ah, much better!
More fixing!
Once I got this far, I played around with the editor a bit and was able to find a few more things that needed fixing. The good thing is that all of the issues are quite easy to fix using JavaScript, CSS, or even HTML.
Remove native spell checking
We’re making a code editor, and code has lots of words and attributes that a browser’s native spell checker will think are misspellings.
Spell checking isn’t a bad thing; it’s just unhelpful in this situation. Is something marked incorrect because it is incorrectly spelled or because the code is invalid? It’s tough to tell. To fix this, all we need is to set the spellcheck
attribute on the <textarea>
to false
:
<textarea id="editing" spellcheck="false" ...>
Handling new lines
Turns out that innerText
doesn’t support newlines (\n
).
The update
function needs to be edited. Instead of using innerText
, we can use innerHTML
, replacing the open bracket character (<
) with <
and replace the ampersand character (&
) with &
to stop HTML escapes from being evaluated. This prevents new HTML tags from being created, allowing the actual source code displays instead of the browser attempting to render the code.
result_element.innerHTML = text.replace(new RegExp("&", "g"), "&").replace(new RegExp("<", "g"), "<"); /* Global RegExp */
Scrolling and resizing
Here’s another thing: the highlighted code cannot scroll while the editing is taking place. And when the textarea is scrolled, the highlighted code does not scroll with it.
First, let’s make sure that both the textarea
and result support scrolling:
/* Can be scrolled */
#editing, #highlighting {
overflow: auto;
white-space: nowrap; /* Allows textarea to scroll horizontally */
}
Then, to make sure that the result scrolls with the textarea, we’ll update the HTML and JavaScript like this:
<textarea id="editing" oninput="update(this.value); sync_scroll(this);" onscroll="sync_scroll(this);"></textarea>
function sync_scroll(element) {
/* Scroll result to scroll coords of event - sync with textarea */
let result_element = document.querySelector("#highlighting");
// Get and set x and y
result_element.scrollTop = element.scrollTop;
result_element.scrollLeft = element.scrollLeft;
}
Some browsers also allow a textarea
to be resized, but this means that the textarea
and result could become different sizes. Can CSS fix this? Of course it can. We’ll simply disable resizing:
/* No resize on textarea */
#editing {
resize: none;
}
Final newlines
Thanks to this comment for pointing out this bug.
Now the scrolling is almost always synchronized, but there is still one case where it still doesn’t work. When the user creates a new line, the cursor and the textarea’s text are temporarily in the wrong position before any text is entered on the new line. This is because the <pre><code>
block ignores an empty final line for aesthetic reasons. Because this is for a functional code input, rather than a piece of displayed code, the empty final line needs to be shown. This is done by giving the final line content so it is no longer empty, with a few lines of JavaScript added to the update
function. I have used a space character because it is invisible to the user.
function update(text) {
let result_element = document.querySelector("#highlighting-content");
// Handle final newlines (see article)
if(text[text.length-1] == "\n") { // If the last character is a newline character
text += " "; // Add a placeholder space character to the final line
}
// Update code
result_element.innerHTML = text.replace(new RegExp("&", "g"), "&").replace(new RegExp("<", "g"), "<"); /* Global RegExp */
// Syntax Highlight
Prism.highlightElement(result_element);
}
Indenting lines
One of the trickier things to adjust is how to handle line indentations in the result. The way the editor is currently set up, indenting lines with spaces works fine. But, if you’re more into tabs than spaces, you may have noticed that those aren’t working as expected.
JavaScript can be used to make the Tab key properly work. I have added comments to make it clear what is happening in the function.
<textarea ... onkeydown="check_tab(this, event);"></textarea>
function check_tab(element, event) {
let code = element.value;
if(event.key == "Tab") {
/* Tab key pressed */
event.preventDefault(); // stop normal
let before_tab = code.slice(0, element.selectionStart); // text before tab
let after_tab = code.slice(element.selectionEnd, element.value.length); // text after tab
let cursor_pos = element.selectionEnd + 1; // where cursor moves after tab - moving forward by 1 char to after tab
element.value = before_tab + "\t" + after_tab; // add tab char
// move cursor
element.selectionStart = cursor_pos;
element.selectionEnd = cursor_pos;
update(element.value); // Update text to include indent
}
}
To make sure the Tab
characters are the same size in both the <textarea>
and the syntax-highlighted code block, edit the CSS
to include the tab-size
property:
#editing, #highlighting, #highlighting * {
/* Also add text styles to highlighing tokens */
[...]
tab-size: 2;
}
The final result
Not too crazy, right? All we have are <textarea>
, <pre>
and <code>
elements in the HTML, a new lines of CSS that stack them together, and a syntax highlighting library to format what’s entered. And what I like best about this is that we’re working with normal, semantic HTML elements, leveraging native attributes to get the behavior we want, leaning on CSS to create the illusion that we’re only interacting with one element, then reaching for JavaScript to solve some edge cases.
While I used Prism.js for syntax highlighting, this technique will work with others. It would even work with a syntax highlighter you create yourself, if you want it to. I hope this becomes useful, and can be used in many places, whether it’s a WYSIWYG editor for a CMS, or even a forms where the ability to enter source code is a requirement like a front-end job application or perhaps a quiz. It is a<textarea>
, after all, so it’s capable of being used in any form — you can even add a placeholder
if you need to!
Although it almost perfectly mirrors the visual look when typing code on a code editor with the syntax highlighting feature built-in, your version still have some flaws, such as typing
<
,>
or&
on the text area, it showed<
,>
and&
respectively instead of the actual code. Also can you please use theinput
event instead of thekeyup
event? Using theinput
event will update the highlighting immediately, whilekeyup
present a rather-annoying delay between typing and showing the character(s) I have typed.That is a good point. You can stop the HTML escapes like
&
by editing the character-replacing line of JavaScript in theupdate
function to replace the&
s, as shown below.Thank you also for reminding me of the
input
event. I will add both changes to the CodePen demo.Very nice solution, love the indentation workaround
This is so awesome!
I used CodeMirror for the same thing and it now seems so overkill!
This should TOTALLY be a web component like
<code-editor>
.Thank you for this comment; you have inspired me to make a
<code-input>
custom element! I have created a CodePen Pen here, and please feel free to use this code. You can use this in any website if you import the JavaScript and a CSS file for Prism.js, as it needs Prism.js to work.Please note that the main “Funky” Prism.js CSS theme (without the editable part I covered here) was made by Lea Verou, not me, as the comments in the code show. You can choose a different theme by replacing the theme part of the CSS code with any theme from the Prism.js website.
I hope this is useful to you, and I hope you enjoy it!
Just wanted to chime in and say that this was a fantastic read. Such an interesting problem to solve, and a great step-by-step breakdown of how you pulled it off.
I also have made this thing before in Google Code but unfortunately the project has gone. Actually, we can use any JavaScript syntax highlighter here, and I think regex-based syntax highlighter is the best for this case because we need to run the syntax highlighter to the whole code on every key stroke.
So in general:
From
<textarea>
:Get characters before selection.
Get characters in selection.
Get characters after selection.
To the
<pre>
element:Do that on every key stroke!
This is very inefficient and you will feel the difference when you open a very large document.
The reason why CodeMirror, Ace, Monaco, and the like can work faster even though they have bigger source code is because they work on the stream. Only parts that are in the view will be highlighted, any text below and above the scroll bar will be ignored.
Notes:
font-size
,font-family
,line-height
,text-indent
andletter-spacing
in<textarea>
and in<pre>
must be same.width
,height
,padding
,margin
,border-width
,outline
andbox-sizing
in<textarea>
and in<pre>
must be same.That’s great !
By taking inspiration from this solution, I was able to separate myself from absolute positioning and sync_scroll. Thanks to the grid display, you can perfectly superimpose the text box and the highlighted display and the text box expands to take the size of the pre element.
HTML
CSS
JS
My highlight function add … in pre element and sometimes, superimpose is not good (without the spans it works well, but I no longer have the highlight). Have you an idea to resolve my problem ? I guess it also appears sometimes in your solution.
Thank you :)
I like how you use CSS Grid to superimpose the elements. However, I do not completely understand the problem you have found. I think it may be related to my reply to Thomas, but if it is not, please expand on the nature of the problem and I will respond as soon as possible.
Try typing until the div overflow is activated and you will see the first problem you ignored.
Thank you for pointing this out. This bug can be easily be fixed by setting the CSS
white-space: nowrap
to both the#editing
and#highlighting
elements to allow the<textarea>
to scroll horizontally. I have added this to the post, and have also attached a code snippet below:thanks!
And is it possible to use it in pre-wrap to disable scrollbar?
I couldn’t figure out the new lines part, what does that
result_element.innerHTML = text.replace(new RegExp("<", "g"), "<");
have to do with new lines?Because I am using
innerHTML
rather thaninnerText
(which does not support newlines), new lines are displayed. As it is placed in a<pre>
element, and<pre>
elements show newlines from the HTML without<br/>
needing to be used, the newlines are shown in the<pre><code>
block. The line of code you mentioned escapes the HTML tags so the browser treats them as text rather than HTML to parse into elements, and is not related to newlines.I have updated the article to make this clearer.
Seems like notion app is using div contentEditable and it works fine… wonder how to fix this…
Really interesting article and technique.
At first, I certainly wouldn’t have looked at merging both textarea and a
<pre>
element, and instead would struggled with an contenteditable :)I think handling new lines can be solved in a more elegant way by using
textContent
instead ofinnerText
, because it does support new lines\n
When the new line at bottom is empty. The allignment in textarea and
<pre>
are missallignment 1 line. The example also got this problem. How to fix it?You can fix this using JavaScript, as I have explained here in the article. Thank you for pointing out this bug!
Hello,
After submitting the code in the textarea into database, is like it will have a problem,or will not?
Will it output as I input it on MySQL database?
Eg
I try that of image embedding, but it’s giving image error, instead of displaying the embedding image code
Thank you for your question of how to integrate this backend; it seems to be something I did not think about enough when writing the article. It seems that you are accidentally parsing the HTML code, rather than placing it in the
value
. I have placed the link to a demo showing how to integrate this backend below.Thank you again.
If I copy a line with tabs from notepad it breaks
For example: ” 1 2 3″
Try copy-paste this and press Ctrl+A and you will see that Editing and Highlighting do not match.
How to fix it?
Thank you for letting me know about this bug. The solution is making the
tab-size
property the same for the<textarea>
andpre code
block. I have updated the article to include this in more detail.When tab is pressed while selection is active, the cursor jumps forward by exactly the selected amount.
This bug can be fixed by replacing
by
inside the
check_tab
functionThank you for this!
I wonder if I’m being stupid, but I can’t get any of the Codepen examples to work – I can select text in the white pseudo-textareas, and click links in them, but I can’t get a cursor to appear in order to type anything. I’ve tried in Safari 15.1 and Firefox 94.0.1 on macOS Big Sur, and Safari on iPadOS 15.1.
Firefox 95.0
The visible cursor remains on the first line. It’s impossible to use, unfortunately.
To make it works on Firefox, use:
on #editing and #highlighting.
In Firefox setting the whitespace to pre-wrap didn’t work for me, but pre did.
on #editing, #highlighting and #highlighting code
That’s great. I made a python editor with this. ㅆThank you
To save the textarea and edit it again, the text code is not colored. You must click on textarear and type on your keyboard for the color to take effect. Can color be applied without the keyboard working?
It appears that although Prism.js auto-highlights all
pre code
elements, with highlight.js you would need to run theupdate()
function,hljs.highlightElement(
[pre code element])
, initially with the element. You could also usehljs.highlightAll()
once. This is demonstrated on this CodePen pen.Sorry for the late reply.
Great stuff, thank you for sharing! I’m only learning HTML/CSS/JS so I can’t really implement this yet, but I understand the essence of this solution and this is a really cool idea.
Hii!! This is an amazing exercise!! I’m having some problems I will ask here in case anyone happens to being struggling with the same issue…
The problem I have is both scrolls are different just in the moment I add a new line at the end. The thing is once I add a new line and before I write any letter, the scroll of the textarea is higher because the highlighter is not adding it yet until something is written and I don’t know what to do.
Any help on that?
Thanks!
I reply myself as the answer is already at inside the post info…BUT!
But in my case now each line has a space as the first character, so every time I add a new line it inserts a space and this is not good.
Why it is not happening to you??
Thanks again!
I am very sorry for the delay; thank you for mentioning this.
Your situation suggests that you may be appending
" "
to thetextarea
‘s value rather than the temporary variable (calledtext
in this article) of text to be displayed in thepre code
. I hope this is useful; if it isn’t the case, please let me know.I want my editable area to scroll on x-axis while typing.
How can I do that?
(Sorry for the late reply; I have been quite busy for the last few weeks.)
The current demo already supports scrolling in both axes (please see Scrolling and Resizing), once a line of text has become longer than the input width.
To ensure the code-input element does not grow too large which could be causing problems, please ensure you have made the width (whatever you want)-32px
I hope this solves your problem; please let me know if it does not.
Hi Oliver Geer, there is a problem happening with this code example that whenever I am pasting code via highlight method as well as calling update (highlighted content from
<code>
) method to update the same on textarea, that time code automatically takes space on left side, don’t know how to fix it.(I am sorry that I did not notice this for a while.)
It appears that you may be copying the indentation as well as code in this scenario, so a seemingly blank section appears on the left. You could delete this manually, or use more JS to find the minimum amount of indentation for each line in the pasted code then delete this amount of whitespace at the start of each.
I hope this is useful.
The
onpaste
event (MDN link) should help you trigger code when text is pasted, and get the pasted code.Out of curiosity, how would you extend
check_tab
to enable indenting all selected text. Right now selecting multiple lines results in replacing the selection with a single tab.You could use some JS logic to identify lines (
element.value.split("\n")
), then in a loop:* Add / remove (if shift is held) indentation
* Also calculate where to move the
selectionStart
andselectionEnd
to based on these indentsIt’s worth mentioning I’ve put a much more customisable version of this in a custom-element JavaScript library called
code-input
on GitHub and the code you are asking about is there in theindent
plugin. If you’re going to use this component in a project, I’d recommend using the library – it’s fully open source and maintained and is designed to let you choose to keep only the features you want. This article is a good introduction to the basics (and predates the library) but the library has many more features.