Examples
Every example here is a complete component. Register it, render it, and it runs.
Counter
const counter = {
state: { count: 0, step: 1 },
handler: {
increment(){ this.state.count += this.state.step },
decrement(){ this.state.count -= this.state.step },
reset(){ this.state.count = 0 }
},
default: `
<div class="counter">
<h2>{state.count}</h2>
<button on-click(decrement)>−{state.step}</button>
<button on-click(increment)>+{state.step}</button>
<button on-click(reset) disabled=!state.count>Reset</button>
<label>
Step
<input type="number" value=state.step
on-input( e => state.step = Number( e.target.value ) || 1 )/>
</label>
</div>`,
stylesheet: `
.counter { display: grid; gap: .75rem; justify-items: center; padding: 1.5rem }
button { padding: .5rem 1rem; cursor: pointer }
button[disabled] { opacity: .4; cursor: default }`
}
Todo list
const todoList = {
state: {
todos: [
{ id: 1, text: 'Learn Lips', done: true },
{ id: 2, text: 'Build something', done: false }
],
draft: '',
filter: 'all'
},
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 )
},
remove( id ){
this.state.todos = this.state.todos.filter( t => t.id !== id )
},
visible(){
switch( this.state.filter ){
case 'active': return this.state.todos.filter( t => !t.done )
case 'done': return this.state.todos.filter( t => t.done )
default: return this.state.todos
}
},
remaining(){ return this.state.todos.filter( t => !t.done ).length }
},
default: `
<div class="todos">
<form on-submit( e => { e.preventDefault(); self.add() } )>
<input placeholder="What needs doing?"
value=state.draft
on-input( e => state.draft = e.target.value )/>
<button type="submit">Add</button>
</form>
<nav class="filters">
<for [name] in=[ 'all', 'active', 'done' ]>
<button class=(state.filter === name && 'on')
on-click( () => state.filter = name )>{name}</button>
</for>
</nav>
<ul>
<for [todo] in=self.visible() by="id">
<li class=(todo.done && 'done')>
<input type="checkbox" checked=todo.done on-change(toggle, todo.id)/>
<span>{todo.text}</span>
<button on-click(remove, todo.id)>×</button>
</li>
</for>
</ul>
<if( !self.visible().length )>
<p class="empty">Nothing here.</p>
</if>
<footer>{self.remaining()} remaining</footer>
</div>`,
stylesheet: `
.todos { max-width: 480px; margin: 0 auto }
form { display: flex; gap: .5rem; margin-bottom: 1rem }
form input { flex: 1; padding: .5rem }
.filters { display: flex; gap: .5rem; margin-bottom: 1rem }
.filters .on { font-weight: 600; text-decoration: underline }
ul { list-style: none; padding: 0 }
li { display: flex; align-items: center; gap: .5rem; padding: .4rem 0 }
li.done span { text-decoration: line-through; opacity: .55 }
li button { margin-left: auto }
.empty { opacity: .6; font-style: italic }`
}
Debounced search
const search = {
state: { query: '', results: [], loading: false, error: null },
_static: { endpoint: 'https://api.example.com/search' },
handler: {
onCreate(){ this.timer = null },
onDestroy(){ clearTimeout( this.timer ) },
onType( e ){
this.state.query = e.target.value
clearTimeout( this.timer )
this.timer = setTimeout( () => this.run(), 300 )
},
async run(){
const q = this.state.query.trim()
if( !q ){
this.state.results = []
return
}
this.state.loading = true
this.state.error = null
try {
const res = await fetch(`${this.static.endpoint}?q=${encodeURIComponent( q )}`)
if( !res.ok ) throw new Error(`Search failed (${res.status})`)
this.state.results = await res.json()
}
catch( err ){ this.state.error = err.message }
finally { this.state.loading = false }
}
},
default: `
<div class="search">
<input type="search" placeholder="Search…"
value=state.query on-input(onType)/>
<if( state.loading )>
<p>Searching…</p>
</if>
<else-if( state.error )>
<p class="error">{state.error}</p>
</else-if>
<else-if( state.results.length )>
<ul>
<for [hit] in=state.results by="id">
<li><a href=hit.url>{hit.title}</a></li>
</for>
</ul>
</else-if>
<else-if( state.query )>
<p>No matches for "{state.query}".</p>
</else-if>
</div>`
}
Tabs with slots
const tabs = {
state: { active: 0 },
handler: {
select( i ){
this.state.active = i
this.emit('change', i )
}
},
default: `
<div class="tabs">
<nav role="tablist">
<for [label, i] in=input.labels>
<button role="tab"
aria-selected=(state.active === i)
class=(state.active === i && 'on')
on-click(select, i)>{label}</button>
</for>
</nav>
<div class="panel"><{input.renderer} index=state.active/></div>
</div>`,
stylesheet: `
nav { display: flex; gap: .25rem; border-bottom: 1px solid #ddd }
button { padding: .5rem 1rem; border: 0; background: none; cursor: pointer }
button.on { font-weight: 600; box-shadow: inset 0 -2px 0 currentColor }
.panel { padding: 1rem }`
}
<tabs [index] labels=[ 'Overview', 'Settings' ] on-change(tabChanged)>
<switch( index )>
<case is=0><overview-panel/></case>
<case is=1><settings-panel/></case>
</switch>
</tabs>
Router
const home = { default: `<h1>Home</h1>` }
const about = { default: `<h1>About</h1>` }
const article = {
default: `
<article>
<h1>{input.params.slug}</h1>
<p>Category: {input.params.category}</p>
<if( input.query.ref )><small>via {input.query.ref}</small></if>
</article>`
}
const app = {
_static: {
routes: [
{ path: '/', template: home, default: true },
{ path: '/about', template: about },
{ path: '/blog/:category/:slug', template: article }
]
},
handler: {
afterNavigate({ toState }){ document.title = toState.path },
notFound( path ){ console.warn('no route for', path ) },
go( path ){ this.context.navigate( path ) }
},
default: `
<div class="app">
<nav>
<a on-click( () => self.go('/') )>Home</a>
<a on-click( () => self.go('/about') )>About</a>
</nav>
<router global routes=static.routes
on-after(afterNavigate)
on-not-found(notFound)/>
</div>`
}
lips.root( app, '#app')
Recursive tree
Components may render themselves — the data decides where recursion stops.
const treeNode = {
state: { open: true },
handler: {
toggle(){ this.state.open = !this.state.open }
},
default: `
<li>
<if( input.node.children )>
<button on-click(toggle)>{state.open ? '▾' : '▸'}</button>
</if>
<span>{input.node.name}</span>
<if( state.open && input.node.children )>
<tree-list nodes=input.node.children/>
</if>
</li>`
}
const treeList = {
default: `
<ul>
<for [key, node] in=input.nodes>
<tree-node node=node key=key/>
</for>
</ul>`
}
lips.register('tree-node', treeNode )
lips.register('tree-list', treeList )
lips.root({
state: {
tree: new Map([
[ 'a', { name: 'Documents', children: new Map([
[ 'a1', { name: 'notes.txt' } ]
]) } ],
[ 'b', { name: 'Pictures' } ]
])
},
default: `<tree-list nodes=state.tree/>`
}, '#app')
Because state.tree is a reactive Map, state.tree.set( … ) from anywhere updates the
tree — including nested Maps at any depth.
Live template preview
A minimal editor loop using hot-swap.
import Lips, { compileTemplate } from '@lipsjs/lips'
const lips = new Lips()
const preview = lips.render('preview', {
state: { count: 0 },
handler: { inc(){ this.state.count++ } },
default: `<button on-click(inc)>{state.count}</button>`
})
preview.appendTo('#preview')
document.querySelector('#source').addEventListener('input', e => {
const { ir, diagnostics } = compileTemplate( e.target.value )
const errors = diagnostics.filter( d => d.severity === 'error' )
if( errors.length ){
showErrors( errors ) // each has code, message, hint, line, col
return
}
const { changes, salvaged } = preview.swap( ir )
console.log(`${changes.length} regions patched, ${salvaged.length} instances kept`)
})
The counter keeps its value across every edit that compiles.
Form with validation
const signup = {
state: { email: '', password: '', touched: {}, submitting: false },
_static: {
isEmail: v => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test( v )
},
handler: {
field( name, e ){
this.state[ name ] = e.target.value
this.state.touched[ name ] = true
},
emailError(){
if( !this.state.touched.email ) return null
if( !this.state.email ) return 'Email is required'
if( !this.static.isEmail( this.state.email ) ) return 'That does not look like an email'
return null
},
passwordError(){
if( !this.state.touched.password ) return null
return this.state.password.length < 8 ? 'At least 8 characters' : null
},
valid(){
return this.state.email && this.state.password.length >= 8
&& !this.emailError() && !this.passwordError()
},
async submit( e ){
e.preventDefault()
if( !this.valid() ) return
this.state.submitting = true
try { await createAccount( this.state.email, this.state.password ) }
finally { this.state.submitting = false }
}
},
default: `
<form on-submit(submit) novalidate>
<label>
Email
<input type="email" value=state.email on-input(field, 'email')/>
<if( self.emailError() )><small class="err">{self.emailError()}</small></if>
</label>
<label>
Password
<input type="password" value=state.password on-input(field, 'password')/>
<if( self.passwordError() )><small class="err">{self.passwordError()}</small></if>
</label>
<button type="submit" disabled=(!self.valid() || state.submitting)>
{state.submitting ? 'Creating…' : 'Create account'}
</button>
</form>`,
stylesheet: `
form { display: grid; gap: 1rem; max-width: 360px }
label { display: grid; gap: .3rem }
input { padding: .5rem }
.err { color: #c00 }`
}
Note on-input(field, 'email') — declared arguments come first, and the DOM event is appended
last, so the handler signature is field( name, event ).