Drops

How much code it would take
with other frameworks?

Counter

<div :scope="{ count: 0 }">
  <button :onclick="count++">
    Clicked <span :text="count">0</span> times
  </button>
</div>

Temperature converter

<div :scope="{
  c: 0, f: 32,
  setC(v) { c=v; f=c*9/5+32 },
  setF(v) { f=v; c=(f-32)*5/9 }
}">
  <input type="number" :value="c"
    :change="setC" /> °C =
  <input type="number" :value="f"
    :change="setF" /> °F
</div>
°C = °F

Flight booker

<div :scope="{
  mode: 'one-way',
  go: '2025-06-01',
  back: '2025-06-15',
  setMode(v) { mode = v },
  setGo(v) { go = v },
  setBack(v) { back = v },
  get ok() {
    return mode === 'one-way'
      || back >= go }
}">
  <select :value="mode"
    :change="setMode">
    <option value="one-way">One-way</option>
    <option value="return">Return</option>
  </select>
  <input type="date" :value="go"
    :change="setGo" />
  <input type="date" :value="back"
    :change="setBack"
    :disabled="mode==='one-way'" />
  <button :disabled="!ok">Book</button>
</div>

Timer

<div :scope="{ max: 15, elapsed: 0,
  setMax(v) { this.max = v } }"
  :fx="() => {
    let id = setInterval(
      () => elapsed < max
        && elapsed++, 1000)
    return () => clearInterval(id)
  }">
  <progress :value="elapsed" :max="max">
  </progress>
  <p><span :text="elapsed">0</span>
    / <span :text="max"></span>s</p>
  <input type="range" :value="max"
    :change="setMax" min="1" max="60" />
  <button :onclick="elapsed=0">Reset</button>
</div>

0 / s

CRUD

<div :scope="{
  items: ['Hans','Claus','Karl'],
  sel: 0, name: '', filter: '',
  setFilter(v) { filter = v },
  pick(v) { sel=v; name=items[v] },
  setName(v) { name = v },
  match(n) { return n.toLowerCase()
    .includes(filter.toLowerCase()) },
  add() { items.push(name); name='' },
  upd() { items[sel] = name },
  del() { items.splice(sel,1);
    sel=Math.min(sel,items.length-1) }
}">
  <input :value="filter"
    :change="setFilter" />
  <select size="4" :value="sel"
    :change="pick">
    <option :each="n,i in items"
      :if="match(n)" :value="i"
      :text="n"></option>
  </select>
  <input :value="name" :change="setName"/>
  <button :onclick="add()">Create</button>
  <button :onclick="upd()">Update</button>
  <button :onclick="del()">Delete</button>
</div>

Tabs

<div :scope="{ tab: 'one' }">
  <button :class="{active: tab==='one'}"
    :onclick="tab='one'">One</button>
  <button :class="{active: tab==='two'}"
    :onclick="tab='two'">Two</button>
  <div :if="tab==='one'">First.</div>
  <div :else>Second.</div>
</div>

First tab content.

Second tab content.

Third tab content.

<div :scope="{ open: false }">
  <button :onclick="open=true">Open</button>
  <dialog :if="open"
    :onclick.self="open=false"
    :onkeydown.window.escape="open=false">
    <h2>Hello</h2>
    <p>Content here.</p>
    <button :onclick="open=false">Close</button>
  </dialog>
</div>
Hello

Content here.

Accordion

<div :each="item, i in items"
  :scope="{ open: i===0 }">
  <button :onclick="open=!open"
    :text="item.title"></button>
  <div :if="open" :text="item.body"></div>
</div>
<div :scope="{ open: false }">
  <button :onclick="open=!open">Menu ▾</button>
  <ul :if="open">
    <li :onclick="open=false">Edit</li>
    <li :onclick="open=false">Copy</li>
    <li :onclick="open=false">Delete</li>
  </ul>
</div>

Checkbox group

<div :scope="{
  sel: ['Apple','Cherry'],
  all: ['Apple','Banana','Cherry'],
  has(f) { return sel.includes(f) },
  flip(f,on) { on ? sel.push(f)
    : sel.splice(sel.indexOf(f),1) }
}">
  <label :each="f in all">
    <input type="checkbox" :value="has(f)"
      :onchange="e=>flip(f,e.target.checked)"/>
    <span :text="f"></span>
  </label>
  <p>: <b :text="sel.join(', ')"></b></p>
</div>

Selected:

Responsive grid

<div :scope="{ cols: 3 }"
  :resize="({width}) => cols =
    Math.max(1,Math.floor(width/120))">
  <div :style="{ display:'grid',
    gridTemplateColumns:
      'repeat('+cols+',1fr)' }">
    <div :each="n in 6" :text="n"></div>
  </div>
</div>

Dark mode

<button :scope="{ dark: false }"
  :onclick="dark = !dark"
  :fx="document.documentElement.style
    .colorScheme = dark ? 'dark' : 'light'"
  :text="dark ? '☀️ Light' : '🌙 Dark'">
</button>

Accent color

<div :scope="{ h: 262 }">
  <button :onclick="h=262">Blue</button>
  <button :onclick="h=180">Teal</button>
  <button :onclick="h=0">Rose</button>
  <button :onclick="h=80">Amber</button>
  <div :style.document="{
    '--accent-h': h }"></div>
</div>

Sortable table

<div :scope="{
  rows: [
    {name:'Alice', age:32},
    {name:'Bob', age:25},
    {name:'Carol', age:41}],
  col: 'name', asc: true,
  sort(c) {
    if (col===c) asc=!asc
    else { col=c; asc=true }
    rows.sort((a,b) => asc
      ? (a[col]>b[col]?1:-1)
      : (a[col]<b[col]?1:-1))
  }
}">
  <table>
    <tr>
      <th :onclick="sort('name')">Name</th>
      <th :onclick="sort('age')">Age</th>
    </tr>
    <tr :each="r in rows">
      <td :text="r.name"></td>
      <td :text="r.age"></td>
    </tr>
  </table>
</div>
Name Age

Color converter

<div :scope="{
  hex: '#1548c1',
  r:21, g:72, b:193,
  setHex(v) {
    hex = v;
    let n = parseInt(v.slice(1), 16);
    r=n>>16; g=n>>8&255; b=n&255
  },
  setRGB() {
    hex = '#' + [r,g,b]
     .map(v => v.toString(16)
     .padStart(2,'0')).join('')
  }
}">
  <input :value="hex" :change="setHex" />
  <div :style="{background: hex,
    width:'3em', height:'3em'}"></div>
  <label>R <input type="range"
    :value="r" :change="v => {r=v; setRGB()}"
    min="0" max="255" /></label>
  <label>G <input type="range"
    :value="g" :change="v => {g=v; setRGB()}"
    min="0" max="255" /></label>
  <label>B <input type="range"
    :value="b" :change="v => {b=v; setRGB()}"
    min="0" max="255" /></label>
</div>

rgb(, , )

Tip calculator

<div :scope="{
  bill: 50, pct: 15, split: 2,
  setPct(v) { pct = v },
  setSplit(v) { split = v },
  setBill(v) { bill = v },
  get tip() { return bill*pct/100 },
  get total() { return bill+tip },
  get each() { return total / split }
}">
  <input type="number" :value="bill"
    :change="setBill" />
  <input type="range" :value="pct"
    :change="setPct" min="0" max="50" />
  <span :text="pct + '%'"></span>
  <input type="number" :value="split"
    :change="setSplit" min="1" />
  <b>Tip: $<span :text="tip.toFixed(2)"></span>
   · Each: $<span :text="each.toFixed(2)">
  </span></b>
</div>
Tip: $ · Each: $

Word counter

<div :scope="{ txt: '', set(v){txt=v} }">
  <textarea :value="txt"
    :change="set" rows="4"></textarea>
  <p><span :text="txt.trim()
    ? txt.trim().split(/\s+/).length
    : 0"></span> words ·
  <span :text="txt.length"></span> chars</p>
</div>

words · chars · lines

Unit converter

<div :scope="{
  val: 1,
  from: 'km', to: 'mi',
  units: {km:1, mi:1.60934,
    m:0.001, ft:0.000305,
    yd:0.000914},
  setVal(v) { val = v },
  setFrom(v) { from = v },
  setTo(v) { to = v },
  get result() {
    return val*units[from]/units[to]
  }
}">
  <input type="number" :value="val"
    :change="setVal" />
  <select :value="from" :change="setFrom">
    <option :each="u in Object.keys(units)"
      :value="u" :text="u"></option>
  </select> =
  <b :text="result.toFixed(4)"></b>
  <select :value="to" :change="setTo">
    <option :each="u in Object.keys(units)"
      :value="u" :text="u"></option>
  </select>
</div>
=

Currency converter

<div :scope="{
  amt: 100,
  from: 'USD', to: 'EUR',
  rates: {USD:1, EUR:0.92,
    GBP:0.79, JPY:149.5,
    CAD:1.36, INR:83.1},
  setAmt(v) { amt = v },
  setFrom(v) { from = v },
  setTo(v) { to = v },
  get result() {
    return amt/rates[from]*rates[to]
  }
}">
  <input type="number" :value="amt"
    :change="setAmt" />
  <select :value="from" :change="setFrom">
    <option :each="c in Object.keys(rates)"
      :value="c" :text="c"></option>
  </select> =
  <b :text="result.toFixed(2)"></b>
  <select :value="to" :change="setTo">
    <option :each="c in Object.keys(rates)"
      :value="c" :text="c"></option>
  </select>
</div>
=

Stopwatch

<div :scope="{
  ms: 0, on: false, id: 0,
  start() {
    if (on) { clearInterval(id);
      on=false }
    else { on=true;
      id=setInterval(()=>ms+=10, 10) }
  },
  reset() { clearInterval(id);
    on=false; ms=0 },
  get t() {
    let m = Math.floor(ms/60000),
      s = Math.floor(ms%60000/1000),
      c = Math.floor(ms%1000/10);
    return [m,s,c].map(
      v => String(v).padStart(2,'0'))
      .join(':')
  }
}">
  <p :text="t"></p>
  <button :onclick="start()"
    :text="on ? 'Stop' : 'Start'"></button>
  <button :onclick="reset()">Reset</button>
</div>

00:00:00

Pomodoro

<div :scope="{
  left: 1500, on: false, id: 0,
  work: 25, brk: 5, isWork: true,
  start() {
    if (on) { clearInterval(id);
      on = false }
    else { on = true;
      id = setInterval(() => {
        if (left-- <= 0) {
          isWork = !isWork;
          left = (isWork?work:brk)*60
        }
      }, 1000) }
  },
  reset() { clearInterval(id);
    on=false; isWork=true;
    left=work*60 },
  get mm() {
    return String(Math.floor(left/60))
      .padStart(2,'0') },
  get ss() {
    return String(left%60)
      .padStart(2,'0') }
}">
  <p :text="mm + ':' + ss"></p>
  <p :text="isWork ? 'Work' : 'Break'"></p>
  <button :onclick="start()"
    :text="on ? 'Pause' : 'Start'"></button>
  <button :onclick="reset()">Reset</button>
</div>

25:00

Work

BMI calculator

<div :scope="{
  h: 170, w: 70,
  setH(v) { h = v },
  setW(v) { w = v },
  get bmi() {
    return w / (h/100) ** 2 },
  get cat() {
    let b = bmi;
    return b < 18.5 ? 'Underweight'
      : b < 25 ? 'Normal'
      : b < 30 ? 'Overweight'
      : 'Obese'
  }
}">
  <label>Height
    <input type="range" :value="h"
      :change="setH"
      min="100" max="220" />
    <span :text="h + ' cm'"></span>
  </label>
  <label>Weight
    <input type="range" :value="w"
      :change="setW"
      min="30" max="200" />
    <span :text="w + ' kg'"></span>
  </label>
  <b :text="bmi.toFixed(1)+' — '+cat"></b>
</div>

Loan calculator

<div :scope="{
  p: 200000, r: 5, y: 30,
  setP(v) { p=v }, setR(v) { r=v },
  setY(v) { y=v },
  get monthly() {
    let mr = r/100/12, n = y*12;
    return mr
      ? p*mr*(1+mr)**n/((1+mr)**n-1)
      : p/n
  },
  get total() { return monthly * y*12 }
}">
  <label>Amount $<input type="number"
    :value="p" :change="setP" /></label>
  <label>Rate <input type="range"
    :value="r" :change="setR"
    min="0" max="15" step="0.1" />
    <span :text="r + '%'"></span></label>
  <label>Years <input type="range"
    :value="y" :change="setY"
    min="1" max="30" /></label>
  <b>$<span :text="monthly.toFixed(2)">
  </span>/mo · $<span
    :text="total.toFixed(0)"></span> total
  </b>
</div>
$/mo · $ total

JSON formatter

<div :scope="{
  src: '{\"a\":1}', out: '',
  err: '',
  fmt(v) {
    src = v;
    try { out = JSON.stringify(
      JSON.parse(v), null, 2);
      err = '' }
    catch(e) { err = e.message }
  }
}">
  <textarea :value="src"
    :change="fmt" rows="3"></textarea>
  <p :if="err" :text="err"></p>
  <pre :if="!err" :text="out"></pre>
</div>


Phrase rotator

<b :scope="{
  phrases: [
    'Signal-Powered Reactive Attributes Engine',
    'Structured Presentational Reactive Æsthetic',
    'Simple PRogressive Ænhancement'
  ],
  i: 0 }"
  :fx="() => {
    let id = setInterval(
      () => i = (i+1) % phrases.length,
      2000)
    return () => clearInterval(id) }"
  :text="phrases[i]">
</b>

Tooltip

<span :scope="{ show: false }"
  :onmouseenter="show=true"
  :onmouseleave="show=false"
  style="position:relative">
  Hover me
  <span :if="show"
    style="position:absolute;
      bottom:100%; left:50%;
      transform:translateX(-50%)">
    Tooltip text
  </span>
</span>
Hover me Tooltip text

Copy to clipboard

<div :scope="{
  copied: false,
  copy() {
    navigator.clipboard.writeText(
      'npm i sprae')
    copied = true
    setTimeout(() => copied=false, 1500)
  }
}">
  <code>npm i sprae</code>
  <button :onclick="copy()"
    :text="copied ? '✓ Copied' : 'Copy'">
  </button>
</div>
npm i sprae

Toast

<div :scope="{
  msgs: [],
  toast(text) {
    let m = { text, id: Date.now() }
    msgs.push(m)
    setTimeout(
      () => msgs.splice(
        msgs.indexOf(m), 1), 2500)
  }
}">
  <button :onclick="toast('Saved!')">
    Show toast</button>
  <div :each="m in msgs"
    :text="m.text"></div>
</div>

Star rating

<div :scope="{ rating: 0 }">
  <span :each="n in 5"
    :onclick="rating = n"
    :text="n <= rating ? '★' : '☆'"
    style="cursor:pointer;
      font-size:1.5em">
  </span>
  <span :text="rating + '/5'"></span>
</div>

Tag input

<div :scope="{
  tags: ['sprae','reactive'],
  val: '',
  setVal(v) { val = v },
  add(e) {
    if (e.key==='Enter' && val.trim()){
      tags.push(val.trim())
      val = '' }
  },
  rm(i) { tags.splice(i, 1) }
}">
  <span :each="t, i in tags"
    :onclick="rm(i)">
    <span :text="t"></span> ×
  </span>
  <input :value="val" :change="setVal"
    :onkeydown="add"
    placeholder="Add tag..." />
</div>
×

Typewriter

<span :scope="{
  text: 'Hello, sprae!',
  i: 0, dir: 1 }"
  :fx="() => {
    let id = setInterval(() => {
      i += dir
      if (i >= text.length) dir = -1
      if (i <= 0) dir = 1
    }, 100)
    return () => clearInterval(id) }"
  :text="text.slice(0, i) + '▌'">
</span>

Password strength

<div :scope="{
  pw: '', setPw(v) { pw = v },
  get score() {
    let s = 0
    if (pw.length > 7) s++
    if (/[A-Z]/.test(pw)) s++
    if (/[0-9]/.test(pw)) s++
    if (/[^A-Za-z0-9]/.test(pw)) s++
    return s
  },
  get label() {
    return ['Weak','Fair',
      'Good','Strong'][score] || ''
  }
}">
  <input type="password" :value="pw"
    :change="setPw" />
  <progress :value="score" max="4">
  </progress>
  <span :text="label"></span>
</div>

Price toggle

<div :scope="{
  yearly: false,
  get price() {
    return yearly ? '$96/yr' : '$12/mo'
  },
  get save() {
    return yearly ? 'Save 33%' : ''
  }
}">
  <button :onclick="yearly=!yearly"
    :text="yearly ? 'Yearly' : 'Monthly'">
  </button>
  <b :text="price"></b>
  <small :text="save"></small>
</div>

Countdown

<div :scope="{
  target: '2026-01-01',
  now: Date.now(),
  get diff() {
    return Math.max(0,
      new Date(target) - now) },
  get d() {
    return Math.floor(
      diff / 86400000) },
  get h() {
    return Math.floor(
      diff % 86400000 / 3600000) },
  get m() {
    return Math.floor(
      diff % 3600000 / 60000) }
}" :fx="() => {
  let id = setInterval(
    () => now = Date.now(), 1000)
  return () => clearInterval(id) }">
  <b :text="d+'d '+h+'h '+m+'m'"></b>
</div>
d h m s until 2027