i-html

i-html is a drop in tag that allows for dynamically importing html, inline. It's a bit like an <iframe>, except the html gets adopted into the page.

You might have used something similar before, it might seem familiar to other techniques such as hotwired turbo or similar. You might have even used an element very close to this one, for example the popular include-fragment-element by GitHub. This element is a spiritual successor to that one (more on that below). But this one is <i-html>. Let's talk about it.

Inside the box below is a demonstration of the element. The box is there to help you see it, but the element is inside. If JavaScript is enabled the contents of the box should read "Hello world!". The source page is just an HTML page. There's nothing special about it. Look, go see for yourself: hello.html.

        
  <i-html src="example-responses/hello.html">Loading...</i-html>
        
      
Loading...

The <i-html> tag can be placed on a page, as an empty container. It is completely unstyled (well almost, it has display:contents). Whenever the src= attribute changes, it will fetch the requested resource as text/html and replace its inner contents with the parsed contents of the response. That's essentially all it does. Kind of.

Setting src= and leaving it alone does not really demonstrate the full utility, however, because it is far more capable of interesting things when utilised correctly. People do lots of novel things with <img> tags, and <i-html> should be no different.

Getting started

The easiest way to start is to include the minified script from the CDN:

        
  <script src="https://cdn.jsdelivr.net/npm/i-html-element/i-html.min.js" defer></script>
        
      

or run npm i i-html-element and use the local module:

        
  <script type="module" src="node_modules/i-html-element/i-html.js"></script>
        
      

Features

Like an iframe, <i-html> respects the target= attribute on links. If an <a target=> points to the <i-html>, then the src= is switched with that of the link. A demonstration:

        
  <a href="example-responses/hello.html" target="link-target-example">
    Load hello.html
  </a>
  <a href="example-responses/how-are-you.html" target="link-target-example">
    Load how-are-you.html
  </a><br>
  <i-html id="link-target-example"></i-html>
        
      
Load hello.html Load how-are-you.html

Targeting with form

Also like an iframe, target= on forms is also respected. This means forms can submit to an <i-html> element which will then load in new content. Here's a dummy form, for example:

        
  <form action="example-responses/form-save.html" method="get" target="form-target-example">
    <label>
      A form label:
      <input type="search" />
    </label>
    <button type="submit">Submit</button>
  </form>

  <p>Submitting the form will change the content below:</p>

  <i-html id="form-target-example">Nothing yet</i-html>
        
      

Submitting the form will change the content below:

Nothing yet

Wrapping a form

Wrap a <form> tag in an <i-html> element and you'll have a form that replaces itself. Serve the same form back and you have an "AJAX style" form with no effort.

        
  <i-html id="ajax-form-target-example">
    <form action="example-responses/ajax-form.html" method="get" target="ajax-form-target-example">
      <label>
        A form label:
        <input type="search" />
      </label>
      <button type="submit">Submit</button>
    </form>

    <p>Submitting the form will change the whole form</p>
  </i-html>
        
      

Submitting the form will change the whole form

Form actions

Of course because <forms> work, so does <button formaction=. Example:

        
  <form method="get" target="formaction-example">
    <button formaction="example-responses/form-delete.html" type="submit">
      Delete
    </button>
    <button formaction="example-responses/form-save.html" type="submit">
      Save
    </button>
  </form>

  <p>Submitting the form will change the content below:</p>

  <i-html id="formaction-example">Nothing yet</i-html>
        
      

Submitting the form will change the content below:

Nothing yet

Targeting the response

By default <i-html> will parse the response as HTML, and inject the <body> element's contents into itself, ignoring any unwrapped text nodes. The selection of content to be injected can be customized by setting the target= attribute on <i-html> itself (which defaults to 'body>*' (or 'svg' if accept= is an SVG mime)). If you find you want to take a different element from the response body, set target= to any valid querySelector. If target= is an invalid querySelector it'll revert to 'body>*'. You can check if a target= select is valid by using JavaScript to set .target and then read the value back out, for example:

        
  myEl.target = '/not a valid selector/' // this won't set:
  console.assert(myEl.target === 'body')

  myEl.accept = '.this[is]:valid' // this will set:
  console.assert(myEl.target === '.this[is]:valid')
        
      

In the below example, these two links fetch the same HTML, which has two paragraphs. However the two links target two different <i-html> elements, the first one with a target="p:first-child", the second with a target="p:last-child".

        
  <a href="example-responses/two-paragraphs.html" target="two-paragraphs-a">
    Load two-paragraphs.html into first target
  </a>
  <a href="example-responses/two-paragraphs.html" target="two-paragraphs-b">
    Load two-paragraphs.html into first target
  </a>
  <p><i-html id="two-paragraphs-a" target="p:first-child"></i-html></p>
  <p><i-html id="two-paragraphs-b" target="p:last-child"></i-html></p>
        
      
Load two-paragraphs.html into first target Load two-paragraphs.html into first target

Refetching

If you want to refetch the contents there are several options. The simplest is to use an <a href> element, as clicking the link will cause <i-html> to load each time.

That's not always the easiest though, and so another option is to use a <button command="--load" commandfor=".."> element, which will cause the <i-html> to load the src it has (regardless of the loading= value, see Deferring Loading for more on that).

(For older browsers that don't support command/commandfor buttons)

You'll need to drop in the "invokers-polyfill" package to polyfill this in older browsers.


          <script type="module" src="https://unpkg.com/invokers-polyfill@0.4.2/invoker.js">
          </script>
        
        
  <button command="--load" commandfor="command-load-example">Load</button>
  <i-html id="command-load-example" src="example-responses/hello.html"></i-html>
        
      

Responses can indicate to the client when the content should refresh by adding a `Refresh` header (or a <meta http-equiv=refresh> meta tag), telling <i-html> to refresh after N seconds. This is opt-in though, and needs the allow=refresh attribute on the element.

The `Refresh` header can also have a URL to traverse to. This is also respected, which can make for some interesting properties such as the example below. Lastly, this can be stopped with <button command="--stop":

        
  <button command="--stop" commandfor="refresh-example">Stop</button>
  <i-html allow="refresh" id="refresh-example" src="example-responses/refresh.html"></i-html>
        
      

Content negotiation

By default <i-html> will make a request using the header Accept: text/html. You can customise this by setting the accept= attribute, but it is limited to certain values:

You can slightly customise these types by setting the "subtype combination" or "parameter values", and it'll be smart and make the request with those extras. This means application/xhtml+xml will work, and the expected return content-type must be either application/xml or application/xhtml+xml (but an entirely different sub-type like application/atom+xml will fail). Also more complex mime types like text/fragment+html; charset=utf-8 will work, and the server can respond with the generic text/html or the matching sub-type of text/fragment+html.

Invalid mimes such as application/json will revert to text/html. (If you want to get JSON you don't need this element).

You can check what type works by using JavaScript to set .accept and then read the value back out, for example:

        
  myEl.accept = 'application/json' // this won't set:
  console.assert(myEl.accept === 'text/html')

  myEl.accept = 'text/fragment+html; charset=utf-8' // this will set:
  console.assert(myEl.accept === 'text/fragment+html; charset=utf-8')
        
      

The mime types text/html, image/svg+xml, and application/xml will all use DOMParser to parse the full response body. text/event-stream uses a different streaming mode...

        
  <a href="star-solid.svg" target="svg-example">
    <i-html id="svg-example" src="example-responses/star.svg" accept="image/svg+xml"></i-html>
  </a>
        
      

Streaming Mode

With the accept= attribute set to text/event-stream, it will use an EventSource to listen for Server-Side Events. A connection will be open and for each event fired, the response will be converted using DOMParser and replaced. This allows for many replacements with one request (this could be useful, for example, to show a notification count for a user). The connection will be kept open and replacing contents until either the server drops out, the browser closes the EventSource, the element is removed from the DOM, or the src= or accept= attributes change value.

Insertion Mode

Whether in streaming mode or one-shot mode, the default operation is to replace the contents of the element with the newly downloaded contents. While this is useful most of the time, some of the time it can be even more useful to switch to an append mode. Changing to insert=append will cause new content to be added after all the current children, and using insert=prepend will cause new content to be added before the current children:

        
  <a href="example-responses/prepend-list.html" target="prepend-example">
    Prepend to this list:
  </a>
  <ul>
    <i-html id="prepend-example" target="li" insert="prepend">
      <li>Milk</li>
      <li>Eggs</li>
    </i-html>
  </ul>
        
      
Prepend to this list:

Deferring Loading

Just like <img> and <iframe> tags, <i-html> tags have a loading= attribute. By default they are loading=eager, but changing it to loading=lazy means it will wait until it's visible in the viewport until it makes the request. This also respects CSS styles, so if it's inside an element with display:none then loading=lazy can prevent it from loading until its container is display:block. This is really handy for components like <dialog>s.

Changing to loading=none means it will never load, unless the loading= attribute is changed back to either loading=eager or loading=lazy. This gives you an opportunity to use JavaScript to determine when the loading should occur, for example on click.

A <button command="--load" commandfor=".."> element pointing to a, <i-html> element will force it to load, regardless of the loading= value.

... lazy loading ...

Security

Injecting arbitrary HTML into a page can pose some security risks, so it's important that there's a defence in depth approach to mitigating the risk surface area. <i-html> has some security provisions in place but it's important to not only lean on these, and also apply additional security measures:

With that out of the way, let's talk about the protections <i-html> has in place. It uses fetch() under-the-hood and so must adhere to CORS policies. By default the credentials option is set to 'same-origin' meaning it will send cookies (and respect the Set-Cookie response header) for same-orign requests. To lock this down further setting credentials="omit" will never send/recieve cookies, and setting credentials="include" will open it up to sending/receiving cookies in cross-origin requests.

The default behaviour of <i-html> is restricted in some ways, and these restrictions can be lifted by adding keywords into the allow= attribute. This attribute takes a space-separated list of well-known keywords, and each one will allow a certain type of previously restricted behaviour to happen. The keywords can be combined, so for example allow="refresh media cross-origin" is a valid value.

Cross Origin (allow="cross-origin")

The default behaviour of <i-html> is to only fetch same-origin URLs. This means <i-html src="https://other-site"> won't actually do anything, as you'll need to allow cross-origin requests explicitly. This can be done with the allow="cross-origin" attribute.

It is important to note that allow="cross-origin" only impacts the initial fetch. Without allow="cross-origin" a page may still have URLs or resources (such as images) to other origins, and these will be rendered regardless of this setting.

Sanitization

<i-html> will never explicitly append certain elements into the page, unless you opt in. For example if a response contains an <iframe> element, this will simply be deleted before the contents are injected. If you want <iframe> elements to be injected, you'll need to add the allow="iframe" attribute to the element. <iframe>s aren't the only elements that get sanitized. In fact, there are quite a few. Anything that fetches a sub-resource will be sanitized out by default, only to be opted in with an allow= attribute.

allow="iframe"
This allows the rendering of <iframe> elements in the response body.
allow="i-html"
This allows the rendering of <i-html> elements in the response body.
allow="script"
This allows the rendering of <script> elements in the response body.
allow="style"
This allows the rendering of <style> and <link rel=stylesheet elements in the response body.
allow="media"
This allows the rendering of <img>, <picture>, <video>, <audio>, and <object> elements in the response body.
        
  <i-html src="example-responses/sanitization.html" allow="media"></i-html>
        
      
        
  <a target="theme-switcher" href="example-responses/theme-pink.html">Switch to pink theme</a><br>
  <a target="theme-switcher" href="example-responses/theme-yellow.html">Switch to yellow theme</a><br>
  <a target="theme-switcher" href="example-responses/theme-gray.html">Switch to grayscale theme</a><br>
  <a target="theme-switcher" href="example-responses/empty.html">Reset theme</a><br>
  <i-html id="theme-switcher" allow="style"></i-html>
        
      
Switch to pink theme
Switch to yellow theme
Switch to grayscale theme
Reset theme

Tuning off protections

Of course, if you like to live dangerously, you can turn all of this off by setting allow="*". This is a very bad idea but can be useful during development.

Declarative Shadow DOM

Support for parsing fragments of HTML which include declarative shadow DOM (DSD) templates via Document.parseHTMLUnsafe recently entered Baseline 2024 status, so <i-html> will use that method of parsing if supported. Older browsers would need a polyfill to handle those cases, otherwise, <i-html> will fall back on new DOMParser().parseFromString which does not support DSD.

Note: the "unsafe" nomenclature of this API simply means the browser won't perform any sanitization. (Coming soon to the web platform, there will be a `parseHTML` counterpart which does.) But as explained above, <i-html> will perform its own sanitizing process unless you choose to opt into the various `allow` directives.

... loading dsd ...

Styling

By default this element is display: contents so it won't effect layout, and it has role=presentation so the container itself has no impact on the accessibility tree. All of the content loaded in, however, will effect layout and the accessibility tree. There are 4 pseudo classes you can use to style it during various stages of its lifecycle, it will always be in one of these states, and never more than one. The states are loading, loaded, errored, and waiting. They can be styled like so:

        
          
        
      
(For older browsers that don't support CSS CustomStateSet you'll need some additional CSS.)

The element will fall back to using attributes like [state-waiting] in older browsers, such as Safari 17.3 and below, Firefox 125 and below, and Chrome 89 and below.

Chrome versions 90-124 used a different syntax for :state(), which used double dashes instead, like :--waiting. There's a small bit of support code to handle those versions and so it will fall back to using syntax like :--waiting if it needs to. If you need to support all of these you'll need something more like:


          
        

Here are some details for what each of these states mean:

Events

There are a wealth of events that are fired for each stage of the element's lifecycle. Just like <img> and <iframe> elements, <i-html> elements dispatch loadstart, load, loadend, and error events to announce which stage of the loading process they're in. They also dispatch beforeinsert, and inserted events.

Questions you might have

What about using <alternative>?

There are many elements like this, but none that are quite like this. I think this is a culmination of the best features of the other elements.

What about using htmx / htmz?

htmx and htmz came as small inspiration for this library. htmx is a great project which demonstrates the power of leveraging HTML, and htmz is a great lightweight alternative that leans into the web platform even futher, but it also lacks some real niceties that you'd want from a component like this. This is why <i-html> exists - to take the concepts of htmz and expand them into a fully-fledged element, adding the missing features. If you want a fully featured and more popular library, try htmx. If you're after a really tiny codebase and leveraging as much of the web platform as possible, htmz looks to be a great choice. If you're after something a little bit in-between, I think <i-html> is the best next step.

Why did you not extend include-fragment?

The include-fragment-element is very similar to this, and I'm the current maintainer of both, so it is within my power (and hubris) to make changes to that element to make it more like this element. However, I think they are spiritually different elements. This element definitely builds upon the success of <include-fragment>, but it also changes some design decisions that aren't worth changing in <include-fragment>, given its large user base and the amount of churn it would take to make those changes. In that regard you might consider this element a "rewrite" or a "major version bump" of <include-fragment, but I think both have a valid use case and certainly both can co-exist, maybe even on the same website.

What are the differences between this and include-fragment then?

Aside from the obvious difference being the name, <inlude-fragment> has a smaller featureset, for example it doesn't support streaming mode, it doesn't support custom CSS states, it doesn't support preventing loadstart events. Those things could be added, but then it also has some big differences that are design decisions, and would be breaking changes. <include-fragment> replaces itself on the page, so a fully loaded fragment no longer exists on the page and the loaded HTML stands in its place. This is different to <i-html> which remains in the DOM, and can re-fetch contents again and again. This design change gives the two elements very different use cases, for example <i-html> respects link and form target= attributes which would seem pointless to do in <include-fragment> with this design choice in mind.