Reproducing the React homepage component example with Stimulus JS

Sometimes I stumble upon a good looking UI and I wonder if I can reproduce it with my stack.

The block below is displayed on the React homepage, I really love the how interaction between the code and the rendered html explains what JSX is all about. So I decided to give it a go.

Since I don't use React, this is a 90% accurate html over the wire reproduction of this interaction I managed to create with my usual stack : ruby / rails / hotwire / stimulus / tailwind.

I tinkered quite a bit before finding the relevant solutions to this problem so please bear in mind that this article is an explanation of what I finally managed to do but does not account for the hours of trial and error.

Obviously, The React homepage uses a React library to create this interaction. So I had to find a library that kinda-does-the-same-thing available in vanilla js.

Which means:

  • highlights code syntax
  • has an api to allow interactions with the DOM.

And that library is Shiki. It's been great working with it but the api needs quite some work to work with stimulus:

  • Shiki processes a code block inside an html element, and this code does not care about its html rendering until it is fully processed. We cannot simply add stimulus actions or add css classes on the relevant lines of the code block like we would on a simpler stimulus use case.
  • Before rendering, Shiki creates a data structure named Hast (for HTML Abstract Syntax Tree), which is composed of nodes and spans. You can add stuff to those nodes / spans before rendering.

One step at a time

The plan is to answer those questions:

  • How can I highlight one line in the code block when hovering it?
  • How can I highlight a line in the rendered html?
  • How can I highlight one node in the rendered html when hovering one line in the code block?
  • How can I create couples of code line and html node to highlight?

How can I highlight one line in the code block when hovering it?

I won't explain the whole Stimulus process, let's just assume that the JS code below is inside a stimulus controller and the shiki library is installed. The HTML code gives you an overview of what's needed to make it work

<div data-controller="shiki"> <div data-shiki-target="code"> <!-- lots of code in a block --> </div> </div>

Shiki JS allows to customize the rendered html via transformers. Here, we want to customize the line method, which fortunately has a very useful parameter: lineNumber.

We then have to use the method addClassToHast to add custom classes to the node when hovering.

// shiki controller.js import { Controller } from '@hotwired/stimulus' import { codeToHtml } from 'shiki' export default class extends Controller { static targets = ['code'] connect () { this.setup() } async setup() { const html = await codeToHtml( this.codeTarget.textContent, { // ... transformers: [ { line(node, lineNumber) { if (lineNumber === 4) { this.addClassToHast(node, 'hover:bg-slate-600 w-full inline-block') } }, // ... }, ] }) this.codeTarget.innerHTML = html }

How can I highlight a line in the rendered html?

This time, the recipe is quite simple: we need an event and an effect. This is pretty much a standard Stimulus controller. On click (or any event), highlight the highlightable target.

<div data-controller="stabilo"> <button data-action="stabilo#highlight">Click to highlight</button> <div data-stabilo-target="highlightable"> Hello </div> </div>

// stabilo_controller.js export default class extends Controller { static targets = ["highlightable"] connect() { this.highlightClasses = "outline outline-2 outline-offset-4 outline-secondary" } highlight() { this.highlightableTarget.classList.add(...this.highlightClasses.split(" ")) } }

How can I highlight one node in the rendered html when hovering one line in the code block?

Given that we can now highlight a line in the rendered html, we need a way to trigger the action from the shiki controller. We could add arguments to shiki, but it would mean that the controller would take a whole load of arguments if we want to highlight several divs later. Since the shiki controller accepts targets, we can take advantage of it to sort of invert the dependency.

We need to:

  • add a new target to the shiki controller to target the node we want to highlight.
  • create a custom data property which will be used from the shiki controller to populate the action on the code node.
One might ask: Why don't you simply put the action in the shiki controller? I noticed many strong dependencies inside the shiki controller and while creating this article I decided to tackle them to make the shiki controller easier to reuse and as isolated as possible from the outside world. That's why I rely quite a lot on targets and data attributes to allow shiki to respond to several actions without knowing anything about the other controllers.
<div data-controller="stabilo shiki"> <div data-shiki-target="code"> <!-- lots of code in a block --> </div> <div data-stabilo-target="highlightable" data-shiki-target="controllable" data-shiki-action="mouseover->stabilo#highlight"> Hello </div> </div>

// shiki controller.js export default class extends Controller { static targets = ['code', "controllable"] // ... async setup() { // ... transformers: [ { // ... line(node, lineNumber) { if (lineNumber === 4) { this.addClassToHast(node, 'hover:bg-slate-600 w-full inline-block') node.properties['data-action'] = this.controllableTarget.dataset.shikiAction } }, // ... ] } // ...

How can I create couples of code line and html node to highlight?

Now that we know how to highlight a node when hovering one source code line, we need a way to highlight several nodes from several lines. This means we need:

  • a collection of source code nodes
  • a collection of highlight targets
  • a way to match pairs
  • A way to unhilight nodes on mouse out.

First, we'll define a lines object inside the controller. Then for each target, we'll add a line_number parameter which will be retrieved by the shiki controller. We'll then iterate on the targets to populate the object with the action and line number.

Finally, we'll change the behavior inside the Stabilo controller to handle a collection and match the line with the target when hovering. To do that, we'll use action parameters.

Few things to notice on the new html ⬇️

  • The highlight action has been replaced by highlightMatch
  • On mouseout, we call a new method unHighlightAll
  • I added similar params to both shiki and stabilo to maintain total separation between the two:
    • data-shiki-line-number, which references the line on the code block
    • data-stabilo-match, which references the exact same thing for the stabilo controller.
  • I also added a shiki-action-param, which will allow the shiki controller to pass the line number along with the action, so that the stabilo controller knows what target to highlight without knowing anything about shiki.
<div data-controller="stabilo shiki"> <div data-shiki-target="code"> <!-- lots of code in a block --> </div> <div data-stabilo-target="highlightable" data-shiki-target="controllable" data-shiki-action="mouseover->stabilo#highlightMatch mouseout->stabilo#unHighlightAll" data-shiki-action-param="data-stabilo-match-param", data-shiki-line-number="1" data-stabilo-match="1" > Hello </div> <div data-stabilo-target="highlightable" data-shiki-target="controllable" data-shiki-action="mouseover->stabilo#highlightMatch mouseout->stabilo#unHighlightAll" data-shiki-action-param="data-stabilo-match-param", data-shiki-line-number="4" data-stabilo-match="4" > How are you? </div> </div>

The final shiki controller looks like this, with the new parameter passing the line number as a match without knowing about it.

import { Controller } from '@hotwired/stimulus' import { codeToHtml } from 'shiki' export default class extends Controller { static targets = ['code', "controllable"] connect() { this.lines = {} this.setup() } async setup() { const controller = this const html = await codeToHtml(this.codeTarget.textContent, { lang: 'jsx', theme: 'material-theme-ocean', transformers: [ { tokens(tokens) { controller.createLines() }, line(node, lineNumber) { const currentLine = controller.lines[lineNumber] if (currentLine) { this.addClassToHast(node, 'hover:bg-slate-600 w-full inline-block') node.properties['data-action'] = currentLine.action node.properties[currentLine.actionParam] = currentLine.lineNumber } }, code(node) { this.addClassToHast(node, 'language-js') }, }, ] }) this.codeTarget.innerHTML = html } createLines() { this.controllableTargets.forEach((item) => { let { shikiAction, shikiActionParam, shikiLineNumber } = item.dataset this.lines[Number(shikiLineNumber)] = { action: shikiAction, actionParam: shikiActionParam, lineNumber: shikiLineNumber } }) } }

The stabilo controller looks like this:

You'll notice the highlightMatch method which uses both data from the target and the event to select the node to highlight.

import { Controller } from '@hotwired/stimulus' export default class extends Controller { static targets = ["highlightable"] connect() { this.highlightClasses = "outline outline-2 outline-offset-4 outline-secondary" } highlight() { this.addHighlightClasses(this.highlightableTarget) } unhighlight() { this.removeHighlightClasses(this.highlightableTarget) } highlightMatch(event) { this.highlightableTargets.forEach((target) => { let targetMatch = String(target.dataset.stabiloMatch) let selectedMatch = String(event.params.match) if (targetMatch === selectedMatch) { this.addHighlightClasses(target) } }) } unHighlightAll() { this.highlightableTargets.forEach((target) => { this.removeHighlightClasses(target) }) } addHighlightClasses(target) { target.classList.add(...this.highlightClasses.split(" ")) } removeHighlightClasses(target) { target.classList.remove(...this.highlightClasses.split(" ")) } }

Wrapping things up

It's been quite a ride to create this simili-react-homepage-interaction.

I'm happy with the result, especially because along the way I had to think hard about the best way to decouple those 2 controllers. I had to stray from the usual stimulus controller coordination but I think this can prove useful in a similar context.

This leaves much to be desired though, as the shiki controller is not usable alone right now, and pretty much coupled to the very feature I created:

  • How can I use this controller if I don't have controllable nodes and only the shiki code to deal with?
  • What if I want to display a pop-over when hovering on a word?
  • What if I want to revert the very behavior used in this article? How can I highlight a line (or anything else) by clicking / hovering from another node?

There is much to do with this library!