Welcome to the Weightless Web
Ardi makes it almost too easy to create reactive custom elements that work with any site or framework.
You can use Ardi from NPM or a CDN.
npm i ardi
import ardi, { html } from 'ardi'
ardi({ tag: 'my-component' })
<script type="module">
import ardi, { html } from '//unpkg.com/ardi'
ardi({ tag: 'my-component' })
</script>
Ardi uses a straightforward object-oriented API. To demonstrate the API, we'll be looking at simplified code from the
Define the component's tag. The tag must follow the custom element naming convention. We'll call this component 'podcast-embed'.
ardi({
tag: 'podcast-embed',
})
If you are building a component that extends a default element, you can define the prototype and tag here. Note that Safari still does not support extending built-in elements 😭.
ardi({
extends: [HTMLAnchorElement, 'a'],
})
Ardi renders to the Shadow DOM by default. You can disable this behavior if you need to.
ardi({
shadow: false,
})
Props allow you to configure a component using the element's attributes. To create a property, add a key under props
whose value is an array containing a handler function and (optionally) a default value. The handler takes the string value from the prop's attribute and transforms it, i.e. from a string '4'
to a number 4
. The handler can be a built-in function (like String, Number, or JSON.parse) or an arrow function. Every prop is reactive, which means that whether a prop's value is set internally or via its attribute, the change will trigger a render. Prop values are accessible directly from this
, i.e. this.pagesize
.
Here are the props configured in the podcast demo.
ardi({
props: {
feed: [String, 'https://feeds.simplecast.com/54nAGcIl'],
pagesize: [Number, 10],
pagelabel: [String, 'Page'],
prevpagelabel: [String, 'Prevous Page'],
nextpagelabel: [String, 'Next Page'],
pauselabel: [String, 'pause'],
playlabel: [String, 'play'],
},
})
State is a reactive container for data, which means any change will trigger a render. Values declared in state are accessible from this
, i.e. this.episodes
.
Here is how state is defined in the podcast demo.
ardi({
state: () => ({
feedJSON: {},
nowPlaying: null,
page: 0,
paused: true,
}),
})
μhtml is the default template library, and it's just like JSX except you create your templates using tagged template literals. μhtml is extremely efficient. When the component's state changes, instead of re-rendering an entire element, μhtml makes tiny, surgical DOM updates as-needed.
Event handlers can be applied to an element using React's on
syntax (onClick
) or Vue's @
syntax (@click
). Here is a snippet showing the play/pause button for an episode in the podcast demo.
ardi({
template() {
return html`
...
<button
part="play-button"
@click=${() => this.playPause(track)}
aria-label=${this.nowPlaying === track && !this.paused
? this.pauselabel
: this.playlabel}
>
${this.icon(
this.nowPlaying === track && !this.paused ? 'pause' : 'play'
)}
</button>
...
`
},
})
Lists are handled using the Array.map()
method. In the podcast demo, we will use a map to list the episodes that are returned by the xml feed. Lists generally do not require a key, but in cases where the order of elements changes you can add a key using html.for(key)
.
ardi({
template() {
return html`
...
<div part="episodes">
${this.episodes.map((episode) => {
return html`<div part="episode">...</div>`
})}
</div>
...
`
},
})
Ternary operators are the recommended way to handle conditional rendering. The snippet below shows how elements can be conditionally rendered based on the available data.
ardi({
template() {
return html`
...
<audio ref="player" src=${this.nowPlaying} />
<div part="header">
${image ? html`<img part="image" src=${image} />` : ''}
<div part="header-wrapper">
${title ? html`<p part="title">${title}</p>` : ''}
${author ? html`<p part="author">${author}</p>` : ''}
${link ? html`<a part="link" href=${link}>${link}</a>` : ''}
</div>
</div>
${description ? html`<p part="description">${description}</p>` : ''}
...
`
},
})
If you prefer a more HTML-like syntax, Ardi provides a <if-else>
element that you can use instead. To use it, just assign the if
prop with a condition and nest your element inside. If you want to provide a fallback element, you can assign it to the else
slot and it will be displayed if the condition is falsey. You can see this in action in the
ardi({
template() {
return html`
<if-else if=${image}>
<img part="image" src=${image} />
<svg slot="else" viewBox="0 0 24 24">
<path d="..." />
</svg>
</if-else>
`
},
})
Ardi components use the Shadow DOM by default, which means you can use <slot> tags to project nested elements into your templates. You can use a single default slot or multiple named slots.
The podcast demo has two named slots allowing the pagination button icons to be customized.
ardi({
template() {
return html`
...
<button
part="pagination-prev"
@click=${() => this.page--}
disabled=${this.page > 0 ? null : true}
aria-label=${this.prevpagelabel}
>
<slot name="prev-icon"> ${this.icon('leftArrow')} </slot>
</button>
...
`
},
})
Ardi allows you to add ref attributes to elements in your template, which are accessible from this.refs
.
In the podcast component, the player
ref is used by the togglePlayback
method to control playback.
ardi({
template() {
return html`<audio ref="player" src=${this.nowPlaying} />...`
},
togglePlayback() {
// ...
this.refs.player.play()
// ...
},
})
You can add any number of methods in your component and access them via this
. Custom methods can be used in your template, in lifecycle callbacks, or inside of other methods. For examples, you can view the complete code for the
Ardi has a powerful and easy to use context api, allowing one component to share and synchronize its props or state with multiple child components. You can see this API in action in the
To share context from a parent component, add the context
attribute with a descriptive name, i.e. context="theme"
You can then use this.context("theme")
to reference the element and access its props or state. When a child component uses the context to make changes to the parent element's props or state, the parent element will notify every other child component that accesses the same values, keeping the context synchronized throughout the application.
<ardi-component context="theme"></ardi-component>
Ardi components use the Shadow DOM by default. Elements in the Shadow DOM can access CSS variables declared on the page. Elements can also be styled using part attributes.
You can use Javascript in an inline style attribute.
ardi({
template() {
const { bg, color } = this.context('theme')
return html`<nav style=${`background: ${bg}; color: ${color};`}>...</nav>`
},
})
If you have a lot of CSS, it's cleaner to create a styles
key. Ardi provides a css
helper function to facilitate working with VSCode and other IDEs that support tagged template literals.
import ardi, { css, html } from '//unpkg.com/ardi'
ardi({
template() {
const { bg, color } = this.context('theme')
return html`<nav style=${`--bg: ${bg}; --color: ${color};`}>...</nav>`
},
styles: css`
nav {
background: var(--bg);
color: var(--color);
}
`,
})
If you prefer, you can also use Javascript variables and functions directly in your CSS by creating the styles
key as a function.
ardi({
template() {
return html`<nav>...</nav>`
},
styles() {
const { bg, color } = this.context('theme')
return `
nav {
background: ${bg};
color: ${color};
}
`
},
})
Ardi is a runtime framework, designed to work with any app, site, or platform. Since Sass and Less are build-time languages, no official support is provided. If you want to write styles in sass and use them in your components, you can always compile to native CSS and @import
the file using Ardi's styles
key.
Many Sass features are redundant when using the Shadow DOM. Complex BEM selectors requiring &
nesting are unnecessary because styles are scoped to the component, and CSS has native support for variables. Nesting is even coming soon. That being said, if cannot live without using SASS (or another pre-processor) for prototyping, here is
Ardi has several lifecycle callbacks, providing a convenient way to fetch data or apply effects.
This callback runs as soon as the component is initialized. This is a good place to load data, setup observers, etc.
A great example of this is in the
ardi({
tag: 'ardi-forecast',
created() {
new ResizeObserver(() =>
requestAnimationFrame(
() => (this.small = this.clientWidth <= this.breakpoint)
)
).observe(this)
},
})
This callback runs as soon as the component's template is rendered, allowing you to call methods that access refs defined in the template.
This method runs each time the component renders an update. This was added to support event listeners when writing templates with Handlebars or untagged template literals, but you can use this method for any purpose.
Although props are reactive, meaning the template is automatically updated when a prop's value changes, you may encounter scenarios where you need to handle a property's value manually, i.e. to fetch data or apply an effect. You can use this callback to observe and respond to prop updates.
Here is an example from the
ardi({
tag: 'ardi-forecast',
changed(prop) {
if (
prop.old &&
prop.new &&
['lat', 'lon', 'locale', 'unit'].includes(prop.name)
) {
this.fetchForecast()
}
},
})
This method is called when the component is scrolled into view. You can use the ratio parameter to determine how much of the component should be visible before you apply an effect. Ardi will only create the intersection observer if you include this method, so omit it if you do not intend to use it.
In the
ardi({
tag: 'ardi-forecast',
intersected(r) {
if (!this.gotWeather && r > 0.2) {
this.fetchForecast()
}
},
})
μhtml is tiny, fast and efficient, and we strongly recommend it. However, JSX is king right now, and Handlebars is still holding on strong. That's why Ardi allows you to use whatever template library you prefer. Sample code for each supported option is provided below, for comparison. There is also an interactive CodePen demo showing all three examples.
import ardi, { html } from '//unpkg.com/ardi'
ardi({
tag: 'uhtml-counter',
state: () => ({ count: 0 }),
template() {
return html`
<button @click=${() => this.count++}>
Count: ${this.count}
</button>`
},
})
import ardi, { html } from '//unpkg.com/ardi'
import React from '//cdn.skypack.dev/jsx-dom'
ardi({
tag: 'jsx-counter',
state: () => ({ count: 0 }),
template() {
return (
<button onClick={() => this.count++}>
Count: {this.count}
</button>
)
},
})
With Handlebars (or any template that returns a simple string: i.e. an untagged template literal), event listeners can be added to the rendered
method. If present, the rendered
method will run after each render.
import ardi, { html } from '//unpkg.com/ardi'
import handlebars from 'https://cdn.skypack.dev/[email protected]'
ardi({
tag: 'hbs-counter',
state: () => ({ count: 0 }),
template() {
return handlebars.compile(
`<button ref='counter'>Count: {{count}}</button>`
)(this)
},
rendered() {
this.refs.counter.addEventListener('click', () => this.count++)
},
})