@vanijs/vani 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/DOCS.md CHANGED
@@ -12,7 +12,7 @@ DOM subtree delimited by anchors and only update when you explicitly ask them to
12
12
  ## Install
13
13
 
14
14
  ```bash
15
- pnpm add vani
15
+ pnpm add @vanijs/vani
16
16
  ```
17
17
 
18
18
  ---
@@ -47,6 +47,119 @@ renderToDOM([Counter()], appRoot)
47
47
 
48
48
  ---
49
49
 
50
+ ## JSX mode examples
51
+
52
+ Vani is JS-first and transpiler-free, with an optional JSX adapter. JSX mode requires
53
+ `jsxImportSource` to be set to `@vanijs/vani` and a `.tsx` file.
54
+
55
+ ### 1) JSX counter button
56
+
57
+ ```tsx
58
+ import { component, renderToDOM, type Handle } from '@vanijs/vani'
59
+
60
+ const Counter = component((_, handle: Handle) => {
61
+ let count = 0
62
+ return () => (
63
+ <button
64
+ type="button"
65
+ onclick={() => {
66
+ count += 1
67
+ handle.update()
68
+ }}
69
+ >
70
+ Count: {count}
71
+ </button>
72
+ )
73
+ })
74
+
75
+ renderToDOM([Counter()], document.getElementById('app')!)
76
+ ```
77
+
78
+ ### 2) JSX component inside JS-first components
79
+
80
+ ```tsx
81
+ import { component } from '@vanijs/vani'
82
+ import * as h from '@vanijs/vani/html'
83
+
84
+ const Badge = component<{ label: string }>((props) => {
85
+ return () => <span>{props.label}</span>
86
+ })
87
+
88
+ const Panel = component(() => {
89
+ return () =>
90
+ h.div(
91
+ 'Mixed render:',
92
+ Badge({ label: 'JSX component' }),
93
+ h.span('inside a JS-first component.'),
94
+ )
95
+ })
96
+ ```
97
+
98
+ ---
99
+
100
+ ## Incremental adoption (mounting points)
101
+
102
+ Vani is intentionally small and lightweight, so you don't need to replace your full stack. You can
103
+ adopt it gradually by mounting Vani components inside existing apps (React, Vue, server-rendered
104
+ pages, etc.) using plain DOM elements as mounting points.
105
+
106
+ Benefits:
107
+
108
+ - Gradual migration: move one widget or screen at a time without a rewrite.
109
+ - Minimal surface area: no global runtime or framework lock-in.
110
+ - Clear ownership: a Vani component owns only its subtree between anchors.
111
+ - Easy rollback: remove a mount point and the rest of the app keeps working.
112
+
113
+ ### Example: mount a Vani widget inside React
114
+
115
+ ```tsx
116
+ import { useEffect, useRef } from 'react'
117
+ import { component, div, button, renderToDOM, type Handle } from '@vanijs/vani'
118
+
119
+ // Vani component (local state + explicit updates)
120
+ const VaniCounter = component((_, handle) => {
121
+ let count = 0
122
+ return () =>
123
+ div(
124
+ `Count: ${count}`,
125
+ button(
126
+ {
127
+ onclick: () => {
128
+ count += 1
129
+ handle.update()
130
+ },
131
+ },
132
+ 'Increment',
133
+ ),
134
+ )
135
+ })
136
+
137
+ // React component that hosts the Vani widget
138
+ export function MyReactComponent() {
139
+ const containerRef = useRef<HTMLDivElement>(null)
140
+ const vaniHandlesRef = useRef<Handle[] | null>(null)
141
+
142
+ useEffect(() => {
143
+ if (!containerRef.current) return
144
+
145
+ // Mount Vani into a React-managed DOM element (the mounting point)
146
+ vaniHandlesRef.current = renderToDOM([VaniCounter()], containerRef.current)
147
+
148
+ // Cleanup when React unmounts
149
+ return () => {
150
+ for (const handle of vaniHandlesRef.current ?? []) {
151
+ handle.dispose()
152
+ }
153
+ vaniHandlesRef.current = null
154
+ }
155
+ }, [])
156
+
157
+ return <div ref={containerRef} />
158
+ }
159
+ ```
160
+
161
+ ---
162
+
50
163
  ## Core Concepts
51
164
 
52
165
  ### 1) Components are functions
@@ -63,7 +176,9 @@ const Hello = component(() => {
63
176
 
64
177
  ### 2) Explicit updates
65
178
 
66
- Nothing re‑renders unless you call `handle.update()`:
179
+ Re-renders are always explicit: call `handle.update()` to refresh a component’s subtree. The only
180
+ automatic update is the initial mount (or `clientOnly` during hydration), which schedules the first
181
+ render for you.
67
182
 
68
183
  ```ts
69
184
  import { component, div, button, type Handle } from '@vanijs/vani'
@@ -98,6 +213,587 @@ Each component owns a DOM range delimited by anchors:
98
213
 
99
214
  Updates replace only the DOM between anchors.
100
215
 
216
+ ### 4) Lists and item-level updates
217
+
218
+ Lists are efficient in Vani when each item is its own component. Every item owns a tiny subtree and
219
+ can update itself (or be updated via a ref) without touching siblings. Use `key` to preserve
220
+ identity across reorders.
221
+
222
+ Key ideas:
223
+
224
+ - Represent list data by id (Map or array + id).
225
+ - Render each row as a keyed component.
226
+ - Store a `ComponentRef` per id so you can call `ref.current?.update()` for that item only.
227
+ - Call the list handle only when the list structure changes (add/remove/reorder).
228
+
229
+ Example:
230
+
231
+ ```ts
232
+ import { component, ul, li, input, button, type Handle, type ComponentRef } from '@vanijs/vani'
233
+
234
+ type Todo = { id: string; text: string; done: boolean }
235
+
236
+ const Row = component<{
237
+ id: string
238
+ getItem: (id: string) => Todo | undefined
239
+ onToggle: (id: string) => void
240
+ onRename: (id: string, text: string) => void
241
+ }>((props) => {
242
+ return () => {
243
+ const item = props.getItem(props.id)
244
+ if (!item) return null
245
+ return li(
246
+ input({
247
+ type: 'checkbox',
248
+ checked: item.done,
249
+ onchange: () => props.onToggle(item.id),
250
+ }),
251
+ input({
252
+ value: item.text,
253
+ oninput: (event) => {
254
+ const value = (event.currentTarget as HTMLInputElement).value
255
+ props.onRename(item.id, value)
256
+ },
257
+ }),
258
+ )
259
+ }
260
+ })
261
+
262
+ const List = component((_, handle: Handle) => {
263
+ let order = ['a', 'b']
264
+ const items = new Map<string, Todo>([
265
+ ['a', { id: 'a', text: 'Ship Vani', done: false }],
266
+ ['b', { id: 'b', text: 'Write docs', done: true }],
267
+ ])
268
+
269
+ const refs = new Map<string, ComponentRef>()
270
+ const getRef = (id: string) => {
271
+ let ref = refs.get(id)
272
+ if (!ref) {
273
+ ref = { current: null }
274
+ refs.set(id, ref)
275
+ }
276
+ return ref
277
+ }
278
+
279
+ const getItem = (id: string) => items.get(id)
280
+
281
+ const updateItem = (id: string, next: Partial<Todo>) => {
282
+ const current = items.get(id)
283
+ if (!current) return
284
+ items.set(id, { ...current, ...next })
285
+ refs.get(id)?.current?.update()
286
+ }
287
+
288
+ const toggle = (id: string) => {
289
+ const current = items.get(id)
290
+ if (!current) return
291
+ updateItem(id, { done: !current.done })
292
+ }
293
+
294
+ const rename = (id: string, text: string) => updateItem(id, { text })
295
+
296
+ const add = (text: string) => {
297
+ const id = String(order.length + 1)
298
+ items.set(id, { id, text, done: false })
299
+ order = [...order, id]
300
+ handle.update()
301
+ }
302
+
303
+ const remove = (id: string) => {
304
+ items.delete(id)
305
+ refs.delete(id)
306
+ order = order.filter((value) => value !== id)
307
+ handle.update()
308
+ }
309
+
310
+ return () =>
311
+ ul(
312
+ order.map((id) =>
313
+ Row({
314
+ key: id,
315
+ ref: getRef(id),
316
+ id,
317
+ getItem,
318
+ onToggle: toggle,
319
+ onRename: rename,
320
+ }),
321
+ ),
322
+ button({ onclick: () => add('New item') }, 'Add'),
323
+ button(
324
+ {
325
+ onclick: () => {
326
+ const first = order[0]
327
+ if (first) remove(first)
328
+ },
329
+ },
330
+ 'Remove first',
331
+ ),
332
+ )
333
+ })
334
+ ```
335
+
336
+ This pattern keeps updates local: changing an item triggers only that row’s subtree update, while
337
+ structural list changes re-render the list container and reuse keyed rows.
338
+
339
+ ---
340
+
341
+ ### 5) Forms with explicit submit
342
+
343
+ For forms, you can keep input values in local variables and update the DOM only on submit. This
344
+ matches Vani’s model: read input changes without re-rendering, then call `handle.update()` when the
345
+ user explicitly submits.
346
+
347
+ Example:
348
+
349
+ ```ts
350
+ import { component, form, label, input, button, div, type Handle } from '@vanijs/vani'
351
+
352
+ const ContactForm = component((_, handle: Handle) => {
353
+ let name = ''
354
+ let email = ''
355
+ let submitted = false
356
+
357
+ const onSubmit = (event: SubmitEvent) => {
358
+ event.preventDefault()
359
+ submitted = true
360
+ handle.update()
361
+ }
362
+
363
+ return () =>
364
+ form(
365
+ { onsubmit: onSubmit },
366
+ label('Name'),
367
+ input({
368
+ name: 'name',
369
+ value: name,
370
+ oninput: (event) => {
371
+ name = (event.currentTarget as HTMLInputElement).value
372
+ },
373
+ }),
374
+ label('Email'),
375
+ input({
376
+ name: 'email',
377
+ type: 'email',
378
+ value: email,
379
+ oninput: (event) => {
380
+ email = (event.currentTarget as HTMLInputElement).value
381
+ },
382
+ }),
383
+ button({ type: 'submit' }, 'Send'),
384
+ submitted ? div(`Submitted: ${name} <${email}>`) : null,
385
+ )
386
+ })
387
+ ```
388
+
389
+ The DOM only updates on submit. Input changes mutate local variables but do not trigger a render
390
+ until the user confirms.
391
+
392
+ ---
393
+
394
+ ### 5.1) Inputs and focus
395
+
396
+ Vani replaces a component’s subtree on update. If you re-render on every keystroke, the input node
397
+ is recreated and the browser will drop focus/selection. Prefer uncontrolled inputs and update on
398
+ submit/blur, or split the input into its own component so only a sibling preview re-renders.
399
+
400
+ If you need a controlled input, preserve focus explicitly:
401
+
402
+ ```ts
403
+ import { component, div, input, type DomRef, type Handle } from '@vanijs/vani'
404
+
405
+ const ControlledInput = component((_, handle: Handle) => {
406
+ const ref: DomRef<HTMLInputElement> = { current: null }
407
+ let value = ''
408
+
409
+ const updateWithFocus = () => {
410
+ const prev = ref.current
411
+ const start = prev?.selectionStart ?? null
412
+ const end = prev?.selectionEnd ?? null
413
+
414
+ handle.updateSync()
415
+
416
+ const next = ref.current
417
+ if (next) {
418
+ next.focus()
419
+ if (start != null && end != null) {
420
+ next.setSelectionRange(start, end)
421
+ }
422
+ }
423
+ }
424
+
425
+ return () =>
426
+ div(
427
+ input({
428
+ ref,
429
+ value,
430
+ oninput: (event) => {
431
+ value = (event.currentTarget as HTMLInputElement).value
432
+ updateWithFocus()
433
+ },
434
+ }),
435
+ div(`Value: ${value}`),
436
+ )
437
+ })
438
+ ```
439
+
440
+ ---
441
+
442
+ ### 6) Conditional rendering
443
+
444
+ Conditional rendering is just normal control flow inside the render function. You compute a boolean
445
+ from your local state and return either the element or `null`. Updates are still explicit: call
446
+ `handle.update()` when you want the condition to be re-evaluated and the DOM to change.
447
+
448
+ Example:
449
+
450
+ ```ts
451
+ import { component, div, button, type Handle } from '@vanijs/vani'
452
+
453
+ const TogglePanel = component((_, handle: Handle) => {
454
+ let open = false
455
+
456
+ const toggle = () => {
457
+ open = !open
458
+ handle.update()
459
+ }
460
+
461
+ return () =>
462
+ div(
463
+ button({ onclick: toggle }, open ? 'Hide details' : 'Show details'),
464
+ open ? div('Now you see me') : null,
465
+ )
466
+ })
467
+ ```
468
+
469
+ The `open` flag is local state. When it changes, you call `handle.update()` to re-render the
470
+ component’s subtree; the conditional element is added or removed accordingly.
471
+
472
+ ---
473
+
474
+ ### 7) Scheduling across independent regions
475
+
476
+ In large apps, keep each UI region as its own component root, and schedule updates explicitly. Use
477
+ microtasks for immediate batching and `startTransition()` for non‑urgent work. This lets you control
478
+ _when_ updates happen without hidden dependencies.
479
+
480
+ Strategy:
481
+
482
+ - Give each region its own `handle`.
483
+ - Coalesce multiple changes in the same tick into a single update per region.
484
+ - Use microtasks for urgent updates (input, selection).
485
+ - Use `startTransition()` for expensive or non‑urgent work (filters, reorders).
486
+ - Avoid cascading updates by keeping regions independent and coordinating through explicit APIs.
487
+
488
+ Example scheduler:
489
+
490
+ ```ts
491
+ import { startTransition, type Handle } from '@vanijs/vani'
492
+
493
+ type RegionId = 'sidebar' | 'content' | 'status'
494
+
495
+ const pending = new Set<RegionId>()
496
+ const handles = new Map<RegionId, Handle>()
497
+
498
+ export const registerRegion = (id: RegionId, handle: Handle) => {
499
+ handles.set(id, handle)
500
+ }
501
+
502
+ export const scheduleRegionUpdate = (id: RegionId, opts?: { transition?: boolean }) => {
503
+ pending.add(id)
504
+
505
+ if (opts?.transition) {
506
+ startTransition(flush)
507
+ return
508
+ }
509
+
510
+ queueMicrotask(flush)
511
+ }
512
+
513
+ const flush = () => {
514
+ for (const id of pending) {
515
+ handles.get(id)?.update()
516
+ }
517
+ pending.clear()
518
+ }
519
+ ```
520
+
521
+ This design keeps scheduling predictable: each region updates at most once per flush, and you can
522
+ decide which updates are urgent vs. deferred. If a region needs data from another, call its public
523
+ API first, then schedule both regions explicitly in the same flush.
524
+
525
+ ---
526
+
527
+ ## Advanced patterns
528
+
529
+ These patterns stay explicit while scaling across larger apps.
530
+
531
+ ### Global state with subscriptions
532
+
533
+ Use a small store with `getState`, `setState`, and `subscribe`. Components subscribe once and call
534
+ `handle.update()` on changes.
535
+
536
+ ```ts
537
+ // store.ts
538
+ type Listener = () => void
539
+ type AppState = { count: number }
540
+
541
+ let state: AppState = { count: 0 }
542
+ const listeners = new Set<Listener>()
543
+
544
+ export const getState = () => state
545
+ export const setState = (next: AppState) => {
546
+ state = next
547
+ for (const listener of listeners) listener()
548
+ }
549
+ export const subscribe = (listener: Listener) => {
550
+ listeners.add(listener)
551
+ return () => listeners.delete(listener)
552
+ }
553
+ ```
554
+
555
+ ```ts
556
+ import { component, div, button, type Handle } from '@vanijs/vani'
557
+ import { getState, setState, subscribe } from './store'
558
+
559
+ const Counter = component((_, handle: Handle) => {
560
+ handle.effect(() => subscribe(() => handle.update()))
561
+
562
+ return () => {
563
+ const { count } = getState()
564
+ return div(`Count: ${count}`, button({ onclick: () => setState({ count: count + 1 }) }, 'Inc'))
565
+ }
566
+ })
567
+ ```
568
+
569
+ ### Data fetching + cache invalidation
570
+
571
+ Keep a simple cache and explicit invalidation. Updates are manual and predictable.
572
+
573
+ ```ts
574
+ type Listener = () => void
575
+ const listeners = new Set<Listener>()
576
+ const cache = new Map<string, unknown>()
577
+
578
+ export const subscribe = (listener: Listener) => {
579
+ listeners.add(listener)
580
+ return () => listeners.delete(listener)
581
+ }
582
+
583
+ export const getCached = <T>(key: string) => cache.get(key) as T | undefined
584
+
585
+ export const refresh = async (key: string, fetcher: () => Promise<unknown>) => {
586
+ cache.set(key, await fetcher())
587
+ for (const listener of listeners) listener()
588
+ }
589
+ ```
590
+
591
+ ### Derived (selector) state
592
+
593
+ Compute derived values during render, or cache them when the base state changes. This keeps updates
594
+ explicit and avoids hidden dependencies.
595
+
596
+ ```ts
597
+ const getVisibleItems = (items: string[], filter: string) =>
598
+ filter ? items.filter((item) => item.includes(filter)) : items
599
+
600
+ // In render:
601
+ const visible = getVisibleItems(items, filter)
602
+ ```
603
+
604
+ ### Event bus for cross-feature coordination
605
+
606
+ For decoupled features, use a tiny event bus and update explicitly when events fire.
607
+
608
+ ```ts
609
+ type Listener = (payload?: unknown) => void
610
+ const listeners = new Map<string, Set<Listener>>()
611
+
612
+ export const on = (event: string, listener: Listener) => {
613
+ const set = listeners.get(event) ?? new Set<Listener>()
614
+ set.add(listener)
615
+ listeners.set(event, set)
616
+ return () => set.delete(listener)
617
+ }
618
+
619
+ export const emit = (event: string, payload?: unknown) => {
620
+ const set = listeners.get(event)
621
+ if (!set) return
622
+ for (const listener of set) listener(payload)
623
+ }
624
+ ```
625
+
626
+ ---
627
+
628
+ ## Large-scale app architecture
629
+
630
+ Vani scales best when you keep update paths explicit and module boundaries clear. The core idea is
631
+ to let feature modules own their local state and expose small, explicit APIs for coordination,
632
+ instead of reaching into each other's state or relying on global reactive graphs. At scale, treat
633
+ updates like messages: state lives in a module, views read from the module, and invalidation is
634
+ triggered by module commands.
635
+
636
+ ### Suggested architecture
637
+
638
+ 1. Feature modules (state + commands)
639
+
640
+ Each module exposes:
641
+
642
+ - a small state container
643
+ - read accessors (snapshot getters)
644
+ - explicit commands (mutations) that notify listeners
645
+ - a `subscribe(listener)` for views to bind invalidation
646
+
647
+ 2. View adapters (bind handles)
648
+
649
+ Views subscribe once via `handle.effect()` and call `handle.update()` when their module notifies.
650
+ This keeps invalidation scoped to the subtree that owns the handle.
651
+
652
+ 3. Coordinator (optional)
653
+
654
+ For cross-module workflows, add a thin coordinator that:
655
+
656
+ - orchestrates sequences (e.g. save → refresh → notify)
657
+ - calls public APIs of each module
658
+ - never accesses private state directly
659
+
660
+ 4. Stable, explicit contracts
661
+
662
+ Use interfaces, simple message payloads, or callbacks to avoid implicit coupling. If one feature
663
+ needs another to update, it calls that module's exported command (or `invalidate()` helper) rather
664
+ than mutating shared data.
665
+
666
+ ### Example: Feature module with explicit invalidation
667
+
668
+ ```ts
669
+ import { component, div, type Handle, type Component } from '@vanijs/vani'
670
+
671
+ type User = { id: string; name: string }
672
+
673
+ export type UsersFeatureApi = {
674
+ getUsers: () => User[]
675
+ setUsers: (next: User[]) => void
676
+ refreshUsers: () => Promise<void>
677
+ subscribe: (listener: () => void) => () => void
678
+ }
679
+
680
+ export const createUsersFeature = (): { api: UsersFeatureApi; View: Component } => {
681
+ let users: User[] = []
682
+ const listeners = new Set<() => void>()
683
+
684
+ const notify = () => {
685
+ for (const listener of listeners) listener()
686
+ }
687
+
688
+ const api: UsersFeatureApi = {
689
+ getUsers: () => users,
690
+ setUsers: (next) => {
691
+ users = next
692
+ notify()
693
+ },
694
+ refreshUsers: async () => {
695
+ const response = await fetch('/api/users')
696
+ const data = (await response.json()) as User[]
697
+ api.setUsers(data)
698
+ },
699
+ subscribe: (listener) => {
700
+ listeners.add(listener)
701
+ return () => listeners.delete(listener)
702
+ },
703
+ }
704
+
705
+ const View = component((_, handle: Handle) => {
706
+ handle.effect(() => api.subscribe(() => handle.update()))
707
+ return () => div(api.getUsers().map((user) => div(user.name)))
708
+ })
709
+
710
+ return { api, View }
711
+ }
712
+ ```
713
+
714
+ ### Example: Coordinator calling explicit APIs
715
+
716
+ ```ts
717
+ import type { UsersFeatureApi } from './users-feature'
718
+
719
+ type Coordinator = {
720
+ onUserSaved: () => Promise<void>
721
+ }
722
+
723
+ export const createCoordinator = (users: UsersFeatureApi): Coordinator => {
724
+ return {
725
+ onUserSaved: async () => {
726
+ await users.refreshUsers()
727
+ },
728
+ }
729
+ }
730
+ ```
731
+
732
+ ### Example: Scoped invalidation by key
733
+
734
+ When a large list exists, invalidate only the affected rows.
735
+
736
+ ```ts
737
+ import { component, div, type Handle } from '@vanijs/vani'
738
+
739
+ const rowHandles = new Map<string, Handle>()
740
+
741
+ export const bindUserRow = (id: string, handle: Handle) => {
742
+ rowHandles.set(id, handle)
743
+ return () => rowHandles.delete(id)
744
+ }
745
+
746
+ export const invalidateUserRow = (id: string) => {
747
+ rowHandles.get(id)?.update()
748
+ }
749
+
750
+ export const UserRow = component<{ id: string; name: string }>((props, handle) => {
751
+ handle.effect(() => bindUserRow(props.id, handle))
752
+ return () => div(props.name)
753
+ })
754
+ ```
755
+
756
+ ### Explicit batching (optional)
757
+
758
+ If you dispatch many invalidations in a single tick, queue them and update once per handle.
759
+
760
+ ```ts
761
+ import type { Handle } from '@vanijs/vani'
762
+
763
+ const pending = new Set<Handle>()
764
+ let scheduled = false
765
+
766
+ export const queueUpdate = (handle: Handle) => {
767
+ pending.add(handle)
768
+ if (scheduled) return
769
+ scheduled = true
770
+ queueMicrotask(() => {
771
+ scheduled = false
772
+ for (const item of pending) item.update()
773
+ pending.clear()
774
+ })
775
+ }
776
+ ```
777
+
778
+ ### Challenges with manual invalidation at scale
779
+
780
+ - Update fan-out: one action may need to notify several modules; keep this explicit via a
781
+ coordinator instead of hidden subscriptions.
782
+ - Over-invalidating: calling `handle.update()` too broadly can re-render large subtrees; prefer
783
+ small, keyed targets (row components, feature roots).
784
+ - Under-invalidating: missing an update call leaves views stale; treat "update after state change"
785
+ as part of the module contract and centralize commands.
786
+ - Ordering and race conditions: when multiple modules depend on shared data, update data first, then
787
+ invalidate in a predictable order; avoid interleaving async updates without coordination.
788
+ - Lifecycle leaks: if a handle isn't unsubscribed, updates keep firing; ensure `subscribe()` returns
789
+ a cleanup and is wired through `handle.effect()`.
790
+ - Debugging update paths: without implicit reactivity, you must trace who called `update()`. Keep
791
+ module APIs narrow, name update methods clearly (`refreshUsers`, `invalidateSearch`), and consider
792
+ instrumentation (log or wrap invalidation helpers).
793
+
794
+ Vani trades automatic coordination for transparency. In large apps, that means you should invest in
795
+ clear module boundaries, explicit cross-module APIs, and small invalidation targets.
796
+
101
797
  ---
102
798
 
103
799
  ## API Reference (with examples)
@@ -203,6 +899,26 @@ import { div, span, button, input } from '@vanijs/vani'
203
899
  div(span('Label'), input({ type: 'text' }), button({ onclick: () => {} }, 'Submit'))
204
900
  ```
205
901
 
902
+ ### SVG icons (Lucide)
903
+
904
+ Vani can render SVG strings directly using `renderSvgString()`. With `lucide-static`, import just
905
+ the icon you need (tree-shakable) and render it with explicit sizing and class names.
906
+
907
+ ```ts
908
+ import { component } from '@vanijs/vani'
909
+ import { renderSvgString } from '@vanijs/vani/svg'
910
+ import { Github } from 'lucide-static'
911
+
912
+ const GithubLink = component(() => {
913
+ return () =>
914
+ renderSvgString(Github, {
915
+ size: 16,
916
+ className: 'h-4 w-4',
917
+ attributes: { 'aria-hidden': 'true' },
918
+ })
919
+ })
920
+ ```
921
+
206
922
  ### `classNames(...classes)`
207
923
 
208
924
  Utility for composing class names:
@@ -211,7 +927,7 @@ Utility for composing class names:
211
927
  import { classNames, div } from '@vanijs/vani'
212
928
 
213
929
  div({
214
- className: classNames('base', { active: true }, ['p-2', 'rounded']),
930
+ className: classNames('base', { active: true }, ['p-2', 'rounded-xl']),
215
931
  })
216
932
  ```
217
933
 
@@ -237,7 +953,8 @@ Effects are explicit and can return a cleanup function.
237
953
  If you plan to use vani for a SSR/SSG application, you should use effects to run client-only code
238
954
  such as accessing the window object, accessing the DOM, etc.
239
955
 
240
- Effects are very simple, they don't have dependencies and are run once on mount and once on update.
956
+ Effects are very simple and run once during component setup (the component function run). They do
957
+ not re-run on every `handle.update()`; updates only call the render function.
241
958
 
242
959
  ```ts
243
960
  import { component, div } from '@vanijs/vani'
@@ -303,7 +1020,8 @@ const App = component(
303
1020
  )
304
1021
  ```
305
1022
 
306
- They are awaited and the fallback is rendered until the component is ready.
1023
+ In DOM mode, the fallback is rendered until the component is ready. In SSR mode, async components
1024
+ are awaited, so the fallback only renders for `clientOnly` components.
307
1025
 
308
1026
  ---
309
1027
 
@@ -342,8 +1060,47 @@ Use `renderToString()` on the server, then `hydrateToDOM()` on the client.
342
1060
 
343
1061
  Use `renderToString()` at build time to generate a static `index.html`, then hydrate on the client.
344
1062
 
345
- **Important:** Hydration only binds to anchors. It does not render or start effects. Call
346
- `handle.update()` to activate the UI.
1063
+ **Important:** Hydration only binds to anchors for normal components. It does not render or start
1064
+ effects until you call `handle.update()` to activate the UI. Components marked `clientOnly: true` do
1065
+ render on the client during hydration.
1066
+
1067
+ ---
1068
+
1069
+ ## Selective Hydration
1070
+
1071
+ You can hydrate a full page but only **activate** the parts that need interactivity. Since
1072
+ `hydrateToDOM()` returns handles, you choose which ones to `update()`.
1073
+
1074
+ Example: hydrate everything, activate only the header.
1075
+
1076
+ ```ts
1077
+ import { hydrateToDOM, type ComponentRef } from '@vanijs/vani'
1078
+ import { Header } from './header'
1079
+ import { Main } from './main'
1080
+ import { Footer } from './footer'
1081
+
1082
+ const headerRef: ComponentRef = { current: null }
1083
+ const root = document.getElementById('app')!
1084
+
1085
+ // Must match server render order.
1086
+ hydrateToDOM([Header({ ref: headerRef }), Main(), Footer()], root)
1087
+
1088
+ // Activate only the header.
1089
+ headerRef.current?.update()
1090
+ ```
1091
+
1092
+ Alternative: split the page into separate roots and hydrate only the interactive region.
1093
+
1094
+ ```ts
1095
+ const headerRoot = document.getElementById('header-root')!
1096
+ const [headerHandle] = hydrateToDOM([Header()], headerRoot)
1097
+ headerHandle.update()
1098
+ ```
1099
+
1100
+ Notes:
1101
+
1102
+ - Non‑updated components remain inert (no handlers/effects) until you call `update()`.
1103
+ - The hydration list must match the server render order for that root.
347
1104
 
348
1105
  ---
349
1106
 
@@ -368,6 +1125,24 @@ This avoids slow DOM diffing and keeps behavior explicit.
368
1125
 
369
1126
  ---
370
1127
 
1128
+ ## Other Resources
1129
+
1130
+ ### Configuring Tailwind CSS Intellisense (VSCode)
1131
+
1132
+ In order to have proper Tailwind CSS Intellisense code completion and hover documentation with Vani,
1133
+ you need to configure the following settings in your `.vscode/settings.json` file:
1134
+
1135
+ ```json
1136
+ {
1137
+ "tailwindCSS.experimental.classRegex": [
1138
+ ["(?:tw|clsx|cn)\\(([^;]*)[\\);]", "[`'\"`]([^'\"`;]*)[`'\"`]"],
1139
+ "(?:className)=\\s*(?:\"|'|{`)([^(?:\"|'|`})]*)",
1140
+ "(?:className):\\s*(?:\"|'|{`)([^(?:\"|'|`})]*)"
1141
+ ],
1142
+ "tailwindCSS.classAttributes": ["class", "classes", "className", "classNames"]
1143
+ }
1144
+ ```
1145
+
371
1146
  ## License
372
1147
 
373
1148
  MIT