Getting started
Installation
Lips ships as an ES module. There is no UMD or global-script build — use
<script type="module"> or a bundler.
From a CDN
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Lips App</title>
</head>
<body>
<div id="app"></div>
<script type="module">
import Lips from 'https://cdn.jsdelivr.net/npm/@lipsjs/lips/+esm'
// your code here
</script>
</body>
</html>
From npm
npm install @lipsjs/lips
import Lips from '@lipsjs/lips'
Entry points
| Import | Contents | gzip |
|---|---|---|
@lipsjs/lips |
full — runtime, parser/compiler, styles, router | ~21 KB |
@lipsjs/lips/runtime |
precompiled-only — no parser/compiler | ~13 KB |
@lipsjs/lips/precompile |
build-time helpers and the Vite/Rollup plugin | — |
@lipsjs/lips/dev |
unminified full build, for debugging | — |
The runtime entry has no template compiler, so every template must arrive
precompiled. It cannot parse a default string.
Your first component
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Lips Counter</title>
</head>
<body>
<div id="app"></div>
<script type="module">
import Lips from 'https://cdn.jsdelivr.net/npm/@lipsjs/lips/+esm'
const lips = new Lips()
const counter = {
state: {
count: 0
},
handler: {
increment(){ this.state.count++ },
decrement(){ this.state.count > 0 && this.state.count-- }
},
default: `
<div class="counter">
<h2>Counter: {state.count}</h2>
<button class="up" on-click(increment)>+</button>
<button class="down" on-click(decrement)>-</button>
</div>`,
stylesheet: `
.counter {
font-family: sans-serif;
text-align: center;
padding: 1rem;
border: 1px solid #ccc;
border-radius: 4px;
max-width: 300px;
}
button {
padding: 0.5rem 1rem;
margin: 0 0.5rem;
cursor: pointer;
}`
}
lips.render('counter', counter).appendTo('#app')
</script>
</body>
</html>
That covers most of the framework’s surface already:
- a template written as ordinary HTML with
{ … }interpolation - state that is reactive by assignment —
this.state.count++is the whole update - handlers bound to the component, called by
on-click(name) - a scoped stylesheet that cannot leak into other components
render vs root
// Render a named component and place it yourself
const c = lips.render('counter', counter)
c.appendTo('#app')
// Or mount an application root in one call
lips.root(appTemplate, '#app')
render( name, template, input? ) returns a component handle. root( template, selector )
renders and appends in one step, and keeps a reference so lips.dispose() can tear the whole
tree down.
The name you pass to render() is also the stylesheet scope. Two components rendered
under the same name share one injected stylesheet.
Registering components
A component must be registered before a template can refer to it by tag name.
lips.register('user-card', userCard)
lips.register('todo-list', todoList)
lips.root({
default: `
<div class="app">
<user-card name="Ada"/>
<todo-list/>
</div>`
}, '#app')
Registration is looked up lazily at render time, so order does not matter — you can register a child after the parent that uses it.
lips.has('user-card') // → true
lips.unregister('user-card')
Project structure
For anything beyond a page, split components into modules. Lips has no opinion about your file layout; a plain object export is all it needs.
my-lips-app/
├── index.html
├── app.js
└── components/
├── header.js
├── footer.js
└── todo-list.js
// app.js
import Lips from '@lipsjs/lips'
import header from './components/header.js'
import footer from './components/footer.js'
import todoList from './components/todo-list.js'
const lips = new Lips({
context: { theme: 'light', user: null }
})
lips.register('app-header', header)
lips.register('app-footer', footer)
lips.register('todo-list', todoList)
lips.root({
default: `
<div class="app">
<app-header/>
<main><todo-list/></main>
<app-footer/>
</div>`,
stylesheet: `
.app { display: flex; flex-direction: column; min-height: 100vh }
main { flex: 1; padding: 1rem }`
}, '#app')
// components/todo-list.js
export default {
state: {
todos: [
{ id: 1, text: 'Learn Lips', done: false },
{ id: 2, text: 'Build an app', done: false }
],
draft: ''
},
handler: {
add(){
const text = this.state.draft.trim()
if( !text ) return
this.state.todos.push({ id: Date.now(), text, done: false })
this.state.draft = ''
},
toggle( id ){
const todo = this.state.todos.find( t => t.id === id )
todo && ( todo.done = !todo.done )
}
},
default: `
<div class="todo-list">
<h2>Todo</h2>
<div class="add">
<input type="text"
placeholder="Add a todo"
value=state.draft
on-input( e => state.draft = e.target.value )/>
<button on-click(add)>Add</button>
</div>
<ul>
<for [todo] in=state.todos by="id">
<li class=(todo.done ? 'item done' : 'item')>
<input type="checkbox" checked=todo.done on-change(toggle, todo.id)/>
<span>{todo.text}</span>
</li>
</for>
</ul>
</div>`,
stylesheet: `
.todo-list { max-width: 500px; margin: 0 auto }
.add { display: flex; gap: .5rem; margin-bottom: 1rem }
.add input { flex: 1; padding: .5rem }
.item { display: flex; align-items: center; gap: .5rem; padding: .5rem 0 }
.item.done span { text-decoration: line-through; opacity: .6 }`
}
Note by="id" on the <for>. It keys the list so DOM nodes and nested component state
survive a reorder. See keyed lists.
Single-file components
With a bundler you can write .lips files instead — script on top, template below — and have
them compiled at build time:
const state = { count: 0 }
const handler = {
increment(){ this.state.count++ }
}
<div class="counter">
<h2>{state.count}</h2>
<button on-click(increment)>+</button>
</div>
See Precompile & CSP for the plugin setup.
Next
- Core concepts — how reactivity and the lifecycle actually work
- Template syntax — the complete syntax reference