Running JS & HTML in a juptyer notebook¶

2025 May 21st, Angus Thomsen

In this blog post I cover the following:

  • Outputting HTML and Javascript in a notebook.
  • Displaying output from javascript in a notebook.
  • Transferring data from Python to Javascript written in the same notebook.
  • Demostrating how to use the DOM API on data transferred into python

Introduction¶

I was itching to write a GLSL shader, and I've gotten into the habbit of using Jupyter notebooks for small throw away coding ideas. This has mostly been with python, but I tried doing this in Haskell (with little success), I tried R (this was reasonable successful), I tried javascript and Julia but I didn't do much with those. Now I wanted to going to see if I could do some GLSL in a juptyer notebook.

Initially like the earlier languages, I first tried to find a kernel that specifically just ran the shaders with minimal ceramony (kind of like shader toy), but I couldn't find much (although I didn't look that hard). So I decided maybe I can just use HTML and run javascript in it, and that seemed to work. I mean I never got around to actually playing around with shaders but I got a proof of concept working showing the workflow is very much possible.

Using JS in a notebook¶

Basically you just do this with the %%javascript decorator.

In [1]:
%%javascript
console.log(2 + 2);
2

The above works, unfortunately you need to check the developer console to see the output, which is a bit awkward with the notebook workflow. If you throw errors you can see the output of those, I guess you could use that to display output but is a bit nasty. So lets use the %%html decorator to create a post to output messages.

In [2]:
%%html
<div style="border: red dashed 0.5px; padding: 16px">
    <h3>Where our output will go</h3>
    <p>
        Note if you scroll up to the where this is defined you 
        not see any of the list items inserted, those come from 
        a subsequent codeblock.
    </p>
    <ul id="output-list">
    </ul>
</div>

Where our output will go

Note if you scroll up to the where this is defined you not see any of the list items inserted, those come from a subsequent codeblock.

Remember to close tags. While in Jupyter Lab it seems be fine, if you export it as HTML (which is probably how you're reading this), the HTML tag will infact remain open (Juptyer does not correct your HTML).

Outputting from JS¶

Now lets use the list above and show some output.

In [3]:
%%javascript
// I've defined a helper function here to provider a higher level API for displaying text
// also note I've assigned this to window to ensure I can access it in other scripts
window.writeMessage = function (targetId, messageText) {
    const target = document.querySelector(targetId);
    const message = document.createTextNode(messageText);
    const listItem = document.createElement('li');
    listItem.appendChild(message);
    target.appendChild(listItem);
};

writeMessage("#output-list", "scroll down to see where this message came from.");

If you can scroll up you can see the output.

Running JS from a HTML block.¶

Another option is to just to create a script tag in a %%html decorator. Which I guess is less disorientating as you can see the output below the code block. I only had this idea midway through writting this, I haven't given it enough thought to say which I prefer, but you would do it like this:

In [4]:
%%html
<div style="border: red dashed 0.5px; padding: 16px">
    <h3>Example of JS in HTML</h3>
    <ul id="output-list-2">
    </ul>
    <script>
      // reusing the function we defined earlier
      writeMessage("#output-list-2", "look here's some output!");
    </script>
</div>

Example of JS in HTML

To be honest, I actualy prefer this above approach, as it's insular and self contained. Although I'm not sure you end up seeing errors logged below the cell (I haven't used this approach long enough to tell if I've missed something). But I feel it's easier to demostrate to the reader what's going on as I write this, so I've used it for the rest of this post.

File loading utilties¶

Below I've defined some utitlies to load files, as well as output some formatted HTML for stuff like tables.

In [5]:
from IPython.display import HTML

class HtmlUtil:
    @staticmethod
    def inject_data(id: str, data: str):
        display(HTML(f"""<script id="{id}" type="application/json">{(data)}</script>"""))
    
    @staticmethod
    def load_css(stylesheet: str):
        display(HTML(f"""<style>{(stylesheet)}</style>"""))
    
    @staticmethod
    def load_js(script_source: str):
        display(HTML(f"<script>{{ {(script_source)} }}</script>"))

    @staticmethod
    def show_html(message):
        display(HTML(message))
    
    @staticmethod
    def show_table(headers, rows, caption):
        headers = ''.join(f'<th>{h}</th>' for h in headers)
        rows = ''.join(
            f'<tr class="table_row">{''.join( f'<td>{column}</td>' for column in columns)}</tr>'
            for columns in rows
        )
        display(HTML(f"""
            <table>
                <caption>{caption}</caption>
                <tr class="table_row">{headers}</tr>
                {rows}
            </table>
        """))

Loading data into javascript¶

In order to do much of anthign useful beyond making interactive toys, you'll want to be able to load stuff into JS. Browser flavoured JS typically cannot access the file system directly, so I tried using fetch, but I'm using Jupyter Lab in a browser it actually just loaded the HTML for the juptyer editor at that file path 😂.

However we expose data to javascript if we load it in python and store it in a script tag with the type set to something other than application/javascript. Then you can read the data via the DOM API.

I've set that up below, and while I was at it I loaded a CSS file to override some Jupyter Labs styles for my own convenience.

In [6]:
import json
import html

def load_resource(resource):
    with open(resource[2], 'r') as f:
        resource_source = f.read()
        
    match resource:
        case ('css', _, _):
            HtmlUtil.load_css(resource_source)
        case ('js', _, _):
            HtmlUtil.load_js(resource_source)
        case ('json', name, _, res_id):
            HtmlUtil.inject_data(res_id, resource_source)
            resource_source = json.dumps(json.loads(resource_source), indent=2)
    return resource_source

table_rows = [
    (
        resource[1],
        resource[2],
        f"""
        <details>
          <summary class="clickable">click here to see the file</summary>
          <pre class="code">{html.escape(load_resource(resource))}</pre>
        </details>
        """
    )
    for resource in [
        ('css', 'Override Styles', './styles/override.css'),   
        ('css', 'Output Styles', './styles/output.css'),
        ('js', 'VDOM LIB', './scripts/vdom-lib.js'),
        ('json', 'JSON DOM 1', './data/vdom-data-1.json', 'vdom-1'),
        ('json', 'JSON DOM 2', './data/vdom-data-2.json', 'vdom-2'),
    ]
]

HtmlUtil.show_table(['name', 'path', 'file source'], 
                    caption="List of loaded Resources", 
                    rows=table_rows)
List of loaded Resources
namepathfile source
Override Styles./styles/override.css
click here to see the file
* {
    box-sizing: border-box;
}

:root {
    --vp-h1m: pow(min(900px, 90vw) / 900px, 0.7);
    --vp-h2m: pow(min(900px, 90vw) / 900px, 0.6);
    --vp-h3m: pow(min(900px, 90vw) / 900px, 0.5);
    --vp-h4m: pow(min(900px, 90vw) / 900px, 0.4);
    --vp-h5m: pow(min(900px, 90vw) / 900px, 0.3);
    --vp-pm: pow(min(900px, 90vw) / 900px, 0.3);
}

.jp-RenderedHTMLCommon { margin-right: unset; }

.jp-InputArea.jp-Cell-inputArea,
.jp-OutputArea-child {
    display: grid;
    grid-template-columns: calc((100% - min(900px, 90vw))/2) 
                           min(900px, 90vw) 
                           calc((100% - min(900px, 90vw))/2);
}

.jp-InputPrompt {
    width: 100%;
}

@media screen and (max-width: 1040px) {
    .jp-InputPrompt {
        z-index: -111111;
        opacity: 0;
        pointer-events: none;
    }
}

.jp-MarkdownOutput {
    --jp-content-font-size1: calc(18px * var(--vp-pm));
    --jp-content-line-height: 1.65;
    --jp-content-font-family: Georgia, "Helvetica Neue", system-ui, -apple-system, blinkmacsystemfont, "Segoe UI", helvetica, arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"
}

.jp-MarkdownOutput :is(p, ul, ol, quote) {
    letter-spacing: 0.2px
}


.jp-MarkdownOutput :is(h1, h2, h3, h4, h5, h6),
.jp-OutputArea table > caption {
    font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
    font-weight: 700;
    letter-spacing: -0.05em;
}


.jp-MarkdownOutput h1 { margin: 1rem 0 2rem 0; }
.jp-MarkdownOutput :is(h2, h3, h4, h5, h6):is(:first-child,:not(:first-child)) {
    margin: 2rem 0 1.5rem 0; 
}

.jp-MarkdownOutput h1 { 
    font-size: 92px;
    font-size: calc(92px * var(--vp-h1m));
    letter-spacing: -0.06em; 
}
.jp-MarkdownOutput h2 { font-size: 78px; font-size: calc(78px * var(--vp-h2m)); }
.jp-MarkdownOutput h3 { font-size: 50px; font-size: calc(50px * var(--vp-h3m)); }
.jp-MarkdownOutput h4 { font-size: 32px; font-size: calc(32px * var(--vp-h4m)); }
.jp-MarkdownOutput h5 { font-size: 26px; font-size: calc(26px * var(--vp-h5m)); }
.jp-MarkdownOutput h6,
.jp-OutputArea table > caption {
    font-size: 20px;
    font-size: calc(20px * var(--vp-h5m));
    letter-spacing: -0.03em;
}


.jp-CodeMirrorEditor {
    padding: 1em;
}

.jp-CodeCell {
    --jp-code-line-height: 1.5;
    --jp-code-font-family: Monaco, menlo, consolas, "DejaVu Sans Mono", monospace;
}

.jp-cell-toolbar {
    right: calc((100% - 900px) / 2);
}

.ͼ1 .cm-scroller {
    line-height: var(--jp-code-line-height) !important;
    font-family: var(--jp-code-font-family) !important;
}
Output Styles./styles/output.css
click here to see the file
.table_row {
    display: grid;
    width: 100%;
    grid-auto-flow: column;
    grid-template-columns: 125px auto;
    grid-auto-columns: 1fr;
    grid-gap: 8px;
}

pre.code {
    font-family: monospace;
    text-align: left;
    max-height: none;
    overflow-y: scroll;
    padding: 8px;
    /* look I unfortunately have to do this due to how Jupyter seems to prioritise it's own styles */
    background: transparent !important;
    padding: 8px;
}

.blink {
    animation: blink-animation 0.5s steps(5, start) infinite;
    -webkit-animation: blink-animation 0.5s steps(5, start) infinite;
}
@keyframes blink-animation {
    to {
        visibility: hidden;
    }
}
@-webkit-keyframes blink-animation {
    to {
        visibility: hidden;
    }
}

.clickable {
    font-weight: bold;
    text-decoration: underline;
    text-tranform: uppercase;
    color: #0000ff;
    cursor: pointer;
}
VDOM LIB./scripts/vdom-lib.js
click here to see the file
const _enumerate = function *(array) {
  let i = 0;
  for (const el of array) {
    yield [i, el];
    i += 1;
  }
}

const e = (tag, items = [], attrs = {}, events = {}) => ({
  kind: 'element',
  tag: tag.toUpperCase(),
  attrs,
  items,
  events,
});

const t = (text) => ({
  kind: 'text',
  text
})
    
function updateOn(domElement, vElements, oElements) {
  for (const [i, el] of _enumerate(domElement.childNodes)) {
    updateElement(el, domElement, vElements[i], oElements[i]);
  }
}

function _determineUpdateKind(domElement, vElement) {
  const vIsText = vElement.kind === 'text';
  const dIsText = domElement.nodeType === document.TEXT_NODE
  
  if (dIsText && vIsText && domElement.textContent === vElement.text) {
    return 'noop';
  }
  
  if (dIsText) {
    return 'replace-render';
  }

  if (vElement.kind !== 'element' || domElement.tagName !== vElement.tag) {
    return 'replace-render';
  }
    
  return 'traverse';
}

function updateElement(domElement, parent, vElement, oElement) {
  switch (_determineUpdateKind(domElement, vElement)) {
    case 'noop':
      return;
      
    case 'replace-render':
      parent.insertBefore(render(vElement), domElement);
      parent.removeChild(domElement);
      return;

    case 'traverse':
      break;

    default:
      throw new Error('unreachable');
  }
  
  const currLen = domElement.childNodes.length;
  const virtLen = vElement.items.length;
  
  for (let i = virtLen; i < currLen; i++) {
    domElement.removeChild(domElement.childNodes[i]);
  } 
  
  for (const [i, el] of _enumerate(domElement.childNodes)) {
    updateElement(el, domElement, vElement.items[i], oElement.items[i]);
  }
  
  if (currLen < virtLen) {
    renderOnTo(domElement, vElement.items.slice(currLen));
  }
  
  const dAttrSet = new Set(domElement.getAttributeNames());
  const vAttrSet = new Set(Object.keys(vElement.attrs ?? {}));
  
  for (const dAttr of dAttrSet) {
    if (!vAttrSet.has(dAttr)) {
      domElement.removeAttribute(dAttr)
    }
  }
  
  for (const [vAttr, value] of Object.entries(vElement.attrs ?? {})) {
    if (!dAttrSet.has(vAttr) || domElement.getAttribute(vAttr) !== value) {
      domElement.setAttribute(vAttr, value)
    }
  }
  
  const oEventNames = new Set(Object.keys(oElement.events ?? {}));
  const vEventNames = new Set(Object.keys(vElement.events ?? {}));
  
  for (const [oEvent, listener] of Object.entries(oElement.events ?? {})) {
    if (!vEventNames.has(oEvent)) {
      domElement.removeEventListener(oEvent, listener)
    }
  }
  
  for (const [vEvent, listener] of Object.entries(vElement.events ?? {})) {
    if (!oEventNames.has(vEvent)) {
      domElement.addEventListener(vEvent, listener);
    } else if (oElement.events[vEvent] !== listener) {
      domElement.removeEventListener(vEvent, oElement.events[vEvent])
      domElement.addEventListener(vEvent, listener);
    }
  }
}

function render(vElement) {
  if (vElement.kind === 'text') {
    return document.createTextNode(vElement.text);
  }
  
  const element = document.createElement(vElement.tag);
  for (const [property, value] of Object.entries(vElement.attrs ?? {})) {
    element.setAttribute(property, value);
  }
  
  for (const [eventName, handler] of Object.entries(vElement.events ?? {})) {
    element.addEventListener(eventName, handler);
  }
  
  renderOnTo(element, vElement.items);
  return element;
}

function renderOnTo(domElement, vElements) {
  vElements.forEach(el => domElement.appendChild(render(el)));
}

window._vdom = { updateOn, updateElement, renderOnTo, render, e, t }
JSON DOM 1./data/vdom-data-1.json
click here to see the file
[
  {
    "kind": "element",
    "tag": "H4",
    "items": [
      {
        "kind": "text",
        "text": "VDOM Demo"
      }
    ]
  },
  {
    "kind": "element",
    "tag": "P",
    "items": [
      {
        "kind": "text",
        "text": "sample text, this updates every now and then"
      }
    ]
  },
  {
    "kind": "element",
    "tag": "UL",
    "items": [
      {
        "kind": "element",
        "tag": "LI",
        "items": [
          {
            "kind": "text",
            "text": "Item one"
          }
        ]
      },
      {
        "kind": "element",
        "tag": "LI",
        "items": [
          {
            "kind": "text",
            "text": "Item "
          },
          {
            "kind": "element",
            "tag": "STRONG",
            "items": [
              {
                "kind": "text",
                "text": "two"
              }
            ]
          }
        ]
      },
      {
        "kind": "element",
        "tag": "LI",
        "items": [
          {
            "kind": "element",
            "tag": "FONT",
            "attrs": {
              "color": "red"
            },
            "items": [
              {
                "kind": "text",
                "text": "Item"
              }
            ]
          },
          {
            "kind": "text",
            "text": " three"
          }
        ]
      }
    ]
  },
  {
    "kind": "element",
    "tag": "BUTTON",
    "items": [
      {
        "kind": "text",
        "text": "click me!"
      }
    ],
    "events": {}
  }
]
JSON DOM 2./data/vdom-data-2.json
click here to see the file
[
  {
    "kind": "element",
    "tag": "H4",
    "items": [
      {
        "kind": "text",
        "text": "VDOM updated TITLE!"
      }
    ]
  },
  {
    "kind": "element",
    "tag": "P",
    "items": [
      {
        "kind": "text",
        "text": "sample text"
      }
    ]
  },
  {
    "kind": "element",
    "tag": "UL",
    "items": [
      {
        "kind": "element",
        "tag": "LI",
        "items": [
          {
            "kind": "text",
            "text": "Item"
          },
          {
            "kind": "element",
            "tag": "FONT",
            "attrs": {
              "color": "blue"
            },
            "items": [
              {
                "kind": "text",
                "text": " one"
              }
            ]
          }
        ]
      },
      {
        "kind": "element",
        "tag": "LI",
        "items": [
          {
            "kind": "text",
            "text": "Item "
          },
          {
            "kind": "element",
            "tag": "EM",
            "items": [
              {
                "kind": "element",
                "tag": "FONT",
                "attrs": {
                  "color": "red"
                },
                "items": [
                  {
                    "kind": "text",
                    "text": "two"
                  }
                ]
              }
            ]
          }
        ]
      },
      {
        "kind": "element",
        "tag": "LI",
        "items": [
          {
            "kind": "element",
            "tag": "FONT",
            "attrs": {
              "size": 24
            },
            "items": [
              {
                "kind": "text",
                "text": "Item"
              }
            ]
          },
          {
            "kind": "text",
            "text": " three"
          }
        ]
      },
      {
        "kind": "element",
        "tag": "LI",
        "items": [
          {
            "kind": "element",
            "tag": "MARQUEE",
            "attrs": {
              "style": "display: inline-block; color: green"
            },
            "items": [
              {
                "kind": "text",
                "text": "Item 4"
              }
            ]
          }
        ]
      }
    ]
  },
  {
    "kind": "element",
    "tag": "BUTTON",
    "attrs": {
      "disabled": "disabled"
    },
    "items": [
      {
        "kind": "text",
        "text": "never mind"
      }
    ]
  }
]

Click the blue to text see the source of each resource loaded.

Reading the data¶

Above you can see the data I've loaded in HTML. Now I'll show you how to access it.

Live updating HTML example¶

Below I've made a small app that does the following:

  1. Parses the JSON stored above (which is actually just JSON serialised HTML), as well as adding an event to a button. You can't store javascript functions in JSON so we needed to add that after loading the JSON.
  2. Traverses the JSON and outputs HTML resemebling the JSON.
  3. Using setTimeout to run a funciton called updateOn which checks for changes in the outputted HTML to incrementally apply updates. Kind of like virtual DOM.

Our code we'll be outputting to an element with the id #vdom-app, which we'll give a fixed height of 350px to layout repeatedly shifting.

Some Setup.¶

Lets setup some functions.

In [7]:
%%html
<div style="border: red dashed 0.5px; padding: 16px">
    <h3>Fun interactive app example</h3>
    <hr>
    <div id="vdom-app" style="height: 350px; border: red dashed 0.5px; padding: 16px"></div>
</div>
<script>
{
    // "import" our library
    const { renderOnTo, updateOn } = window._vdom;
    
    // get our app root and read data from script tags
    const appRoot = document.querySelector('#vdom-app');
    const vdom = JSON.parse(document.querySelector('#vdom-1').textContent);
    const vdom2 = JSON.parse(document.querySelector('#vdom-2').textContent);

    // add an event to our button
    vdom.at(-1)['events'] = { click: () => alert('hello') };
    
    // setup some local state;
    let curr = vdom, next = vdom2;


    let timeout = null, m = 1;

    const getRefreshrate = () => (1000 + (100000 - 1000) * m)/60;

    const f = function () {
      try {
        updateOn(appRoot, next, curr);
        [curr, next] = [next, curr];
        timeout = setTimeout(f, getRefreshrate());
      } catch (e) {
        console.error(e);
      }
    };

    // Do an initial render and start the loop
    renderOnTo(appRoot, vdom)
    timeout = setTimeout(f, getRefreshrate());

    window._vdomapp = {};
    window._vdomapp.setM = (value) => {
        if (value === m)return; 
        m = value;

        try {
            timeout && clearTimeout(timeout);
            timeout = setTimeout(f, getRefreshrate());
        } catch (e) {
            console.error(e);
        }
    };
}
</script>

Fun interactive app example


For kicks lets add a slider beneath the VDom app.

In [8]:
%%html
<script>
{
    // import our library, and then create a slider.
    const { e, t, render } = window._vdom, initialValue = 0.75;

    window._vdomapp.setM(initialValue);

    const labelId = 'label-' + Math.round(Math.random() * (10 ** 16));
    const update = ({ target: { value } }) => window._vdomapp.setM(value);

    const element = render(e('div', [
        e('input', [], {
            id: labelId,
            type: 'range',
            min: 0,
            max: 1.5,
            value: initialValue,
            step: 0.001,
        }, { mousemove: update, change: update }),
        e('label', [t('VDOM refresh Rate')], { for: 'VDOM refresh Rate' }),
    ], {
        style: "margin-top: 16px; display: grid; grid-template-columns: 1fr auto",
    }));

    document.querySelector('#vdom-app').parentNode.appendChild(element);
}
</script>

Wrapping up¶

Anyways I never got around to trying the GLSL shader, but will probably attempt this later, but I hope this is a useful resource anyone interested in mixing javascript and python in a Notebook workflow.

About the Author¶

This was written by Angus Thomsen, you can follow him online here @Angus_KST.

  • Angus has a background in software engineering previously working at Canva, Atlassian, and various start ups in Sydney.
  • Nowadays he's focusing on obtaining a Economics degree and spends more time looking into Housing Affordability within Sydney.

Typography test¶

Typography test¶

Typography test¶

Typography test¶

Typography test¶
Typography test¶

hello world. Some bold text, some italic text.

  • list item one
  • list item two
  • list item three

Quote item, some bold text, some italic text.

  1. list item one
  2. list item two
  3. list item three