npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@vparchment/tada

v0.32.2

Published

A portable, domain-agnostic frontend design system.

Downloads

277

Readme

Tada Design System

Version: 0.31.0

A portable, domain-agnostic frontend design system.

Philosophy

  • Opinion in organisation, not in style
  • Domain-agnostic: No business logic, no context, no SDKs
  • Props in, callbacks out, slots for composition
  • Accessibility is a first-class concern

Overview

This document captures the architecture, principles, and structure agreed upon for the Petscribers frontend design system. It is intended as the canonical reference before implementation begins.

The system is built around a single governing philosophy: opinion in organisation, not in style. Constraints are principled and enforced by structure rather than convention. The design system is a standalone, portable package — domain-agnostic, independently versioned, and tested in isolation.


Principles

Constraints enforce clarity. Every architectural boundary exists for a specific reason. When a decision is ambiguous, the constraints resolve it mechanically rather than requiring judgment each time.

Components are agnostic to state origin. No component knows or cares whether its props came from a Solid signal, a store, an SSE event, or a hardcoded value. State locality is determined by lifetime and scope, not by library preference.

The package boundary is inviolable. No context consumption, no store access, no SDK knowledge inside the design system package. Props flow in, callbacks flow out, slots handle composition.

The SDK boundary is the ceiling of business logic. Everything below the store layer is pure interface — reflecting values and emitting events. Domain concepts do not leak into the component tree.

Accessibility is philosophical, not regulatory. A11y is a first-class concern at the primitive level and is maintained consistently, not just where it is audited.


Architecture

Full Stack Data Flow

WASM SDK (imperative calls)  ─┐
SSE / Presto-Voila (push)    ─┤──► Store Layer ──► Organisms ──► Composites ──► Primitives
User interactions            ─┘

The store layer is the only place the SDK is imported. Everything below it is reactive to props alone.

State Locality

State lives at the lowest level that owns it. Components are agnostic to the source.

Level                  Tool                    Example
─────────────────────────────────────────────────────────────────
Global / SDK-derived   createStore + context   prescription status, active user
Portal session         createStore + context   selected practice, role context
Form / scoped UI       createStore, scoped     field values, validation state
Component interaction  createSignal            dropdown open, focus state
Ephemeral              createSignal, inline    tooltip visible, hover state

No external state management dependencies. Solid's native primitives (createSignal, createStore, createContext) cover all cases. Existing Zustand usage is migrated opportunistically as components are touched — not as a dedicated migration project.

SSE Lifecycle

SSE connections (via Presto-Voila) are managed in the coordination layer above the store. Components never own connection lifecycle. Solid's onCleanup handles teardown at the appropriate level. From the store's perspective, an SSE event and an SDK call are both just writes — the source is irrelevant.


Design System Package

Identity

  • Separate, independently versioned npm package
  • Domain-agnostic — zero knowledge of Petscribers concepts
  • Portals and future projects consume and extend it
  • Tested in isolation — props in, rendered output out

Layer Structure

design-system/
├── tokens/           # Design tokens — the source of truth for all visual decisions
├── primitives/       # Single elements, a11y, style variants, zero domain state
├── composites/       # Composed primitives, local UI state only
├── components/       # Generic superstructures, unopinionated collections
└── index.ts          # Public API

Tokens

The source of truth for all visual decisions. Tailwind config is derived from tokens, not the other way around. Consumers extend the token layer — they do not override it.

Covers: colour, spacing, typography scale, border radius, shadow, z-index, motion duration.

Tailwind's role is reset + sane defaults + DX for bootstrapping. It is the floor, not the ceiling. The opinion is in how things are organised, not what the styles are.

Primitives

  • Single HTML element with a11y attributes, ARIA roles, keyboard contracts
  • Style variants via Tailwind utility classes
  • Only legitimate state: focus, hover, disabled, error — pure UI micro-state
  • Zero domain concepts, zero context consumption
  • Examples: Button, Input, Checkbox, Radio, Pill, Label, Avatar, Icon, Card, Tabs, Accordion, Select, Dialog, Tooltip, Listbox, Skeleton, Slider, Switch, DateInput, Textarea

Composites

  • Composed primitives acting in concert
  • Local UI state only — open/closed, selected index, keyboard navigation
  • No business concepts, no store access
  • Examples: Form, Combobox, Menu, Carousel, Spinner, Table

The hard composites (Combobox, Menu) carry the meaningful a11y complexity — keyboard contracts, focus trapping, ARIA relationships. These are built from scratch to own the implementation fully, rather than wrapping a third-party headless library.

Toast is explicitly excluded — the existing solution is retained as-is.

Menu

A slide-in panel (drawer) composite built for navigation and contextual UI. The Menu root manages controlled/uncontrolled open state; the remaining parts are consumed inside it.

| Component | Description | |---|---| | Menu | Root context provider. Accepts open, defaultOpen, onOpenChange. | | MenuTrigger | A styled-reset <button> that opens the menu. Accepts style, class. | | MenuSheet | The slide-in panel. Accepts side ('left'|'right', default 'right'), width (default '320px'), fullScreenBelow (breakpoint string or false, default '480px'), closeOnOverlayClick (default true), openAnimation (CSS animation shorthand or false; defaults to a slide-in from side), closeAnimation (CSS animation shorthand or false; defaults to a slide-out toward side). Renders a backdrop overlay and traps focus; Escape closes. | | MenuHeader | Header bar with a title slot and an optional close button. Accepts showClose (default true), closeLabel (default 'Close'), closeButton (custom element). | | MenuContent | Scrollable body region. Accepts style, class. | | MenuFooter | Footer bar pinned to the bottom. Accepts style, class. | | MenuClose | A styled-reset <button> that closes the menu — use anywhere inside Menu. | | MenuFloatingToggle | A position: fixed toggle button that floats above the overlay. Accepts side, insetInline (default '1rem'), insetBlock (default '1rem'), openLabel, closeLabel. Children may be a render function receiving isOpen: Accessor<boolean> so icons can swap dynamically. |

<Menu>
  <MenuTrigger>Open</MenuTrigger>
  <MenuSheet side="right" width="360px">
    <MenuHeader>Settings</MenuHeader>
    <MenuContent>…</MenuContent>
    <MenuFooter>
      <MenuClose>Close</MenuClose>
    </MenuFooter>
  </MenuSheet>
</Menu>

{/* Floating hamburger variant */}
<Menu>
  <MenuFloatingToggle side="left">
    {(isOpen) => (isOpen() ? <CloseIcon /> : <HamburgerIcon />)}
  </MenuFloatingToggle>
  <MenuSheet side="left">…</MenuSheet>
</Menu>

Components

  • Composites forming generic superstructures
  • Structural opinions only — field order, layout relationships, interaction patterns
  • No style opinions, no domain opinions
  • Injectable: what is searched, how results are styled, what happens on selection — all injected by the consumer
  • Examples: SearchInput + Results, Login shell, Drag-and-drop list, Data table, Notification feed, Paginated list

The portability contract at this level: the component owns the relationship between its parts, not the semantics of the content.

Package Boundary Contract

✓ Props in
✓ Callbacks out  
✓ Slots / children for composition
✓ Tailwind config exported for consumer extension

✗ No context consumption
✗ No store access
✗ No SDK imports
✗ No domain vocabulary

Structure

src/
  tokens/       # Design tokens (colors, spacing, etc)
  primitives/   # Atomic UI elements (Button, Input, etc)
  composites/   # Composed primitives (Tabs, Dialog, etc)
  components/   # Generic superstructures (SearchInput, DataTable, etc)

Usage

Linked Containers

Container is a validity-aware primitive that nests to arbitrary depth. Containers wire themselves together automatically via Solid context — a child registers with its nearest Container ancestor on mount and unregisters on cleanup. No manual wiring is needed.

How the tree works

Each container holds two things:

  • Local state — its own localStatus and localReason, set via props.
  • Child states — a Map of every direct child's full ContainerValidityState, populated automatically as children mount and unmount.

From those two things it derives a single status — the worst of its own local status and every direct child's derived status. The visual treatment (border colour, background) always reflects this derived status, not the local one. A container that is locally ok will still render as error if any descendant is in error.

Status severity order: error > warning > pending > ok

reason is local-only. It is never inherited from children. A parent's reason describes only the parent's own failure.

ContainerValidityState

Every container produces and reports this shape upward:

type ContainerValidityState = {
  key: string;                               // stable name within the parent's scope
  status: Status;                            // derived: worst of local + all children
  reason: string | null;                     // local failure reason only
  children: Map<string, ContainerValidityState>; // direct children, recursively
};

Props

| Prop | Type | Default | Description | |---|---|---|---| | name | string | auto-generated | Stable identifier within the parent's scope. Provide an explicit value when the container must be addressable or predictable. | | localStatus | Status | 'pending' | This container's own validity status. | | localReason | string \| null | null | This container's own failure reason. Never propagated upward. | | onValidityChange | (state: ContainerValidityState) => void | — | Fires whenever the container's full validity state changes, including changes triggered by descendants. Use on root containers to observe the complete tree. | | showWhen | (parentState: ContainerValidityState) => boolean | — | Controls whether this container is rendered, based on the parent container's current validity state. Receives the parent's full ContainerValidityState, giving access to sibling containers via parentState.children. When hidden, the container unregisters from its parent and does not affect the parent's derived status. Has no effect on root containers. | | locked | boolean | — | When true, renders a visual overlay over the container's children and marks them as inert, blocking all interaction — mouse, keyboard, focus, and assistive technology. Validity state continues to propagate normally; locking is purely a UI concern. | | overlayClass | string | — | Additional CSS class applied to the lock overlay element. | | overlayStyle | JSX.CSSProperties | — | Inline styles merged into the lock overlay, applied after the defaults. Defaults produce a white overlay at 50% opacity covering the full container. | | overlayContent | JSX.Element | — | Content rendered inside the lock overlay — intended for a status icon or label. | | style | JSX.CSSProperties | — | Merged on top of the container's base + status styles. | | class | string | — | Applied to the root <div>. |

Examples

Flat — root observes multiple children

The simplest pattern: a root container with no local state of its own, observing two named children. onValidityChange fires whenever anything in the tree changes.

const [formState, setFormState] = createSignal<ContainerValidityState | null>(null);

<Container name="root" onValidityChange={setFormState}>
  <Container name="patient" localStatus="ok" />
  <Container name="medication" localStatus="warning" localReason="Review required" />
</Container>

// formState().status → 'warning'  (worst of ok + warning)
// formState().children.get('medication')?.reason → 'Review required'

Driven by signals

localStatus and localReason are reactive — pass signals directly to reflect async validation results.

const [doseStatus, setDoseStatus] = createSignal<Status>('pending');
const [doseReason, setDoseReason] = createSignal<string | null>(null);

// Somewhere in your validation logic:
// setDoseStatus('error');
// setDoseReason('Exceeds maximum dose');

<Container name="prescription">
  <Container name="drug" localStatus="ok" />
  <Container name="dose" localStatus={doseStatus()} localReason={doseReason()} />
</Container>

Deeply nested — error propagates to root

Status bubbles up level by level. A leaf error reaches the root regardless of depth.

<Container name="root" localStatus="ok" onValidityChange={(s) => console.log(s.status)}>
  <Container name="section-a" localStatus="ok">
    <Container name="field-1" localStatus="ok" />
    <Container name="field-2" localStatus="error" localReason="Required" />
  </Container>
  <Container name="section-b" localStatus="ok" />
</Container>

// root's derived status → 'error'
// section-a's derived status → 'error'
// section-b's derived status → 'ok'
// root.reason → null  (reason is local-only; root has no local reason)
// root.children.get('section-a')?.children.get('field-2')?.reason → 'Required'

Gating a submit action

Use onValidityChange on the root to gate actions. The callback receives the full tree, so you can inspect any subtree or just check the top-level status.

const [validity, setValidity] = createSignal<ContainerValidityState | null>(null);
const canSubmit = () => validity()?.status === 'ok';

<Container name="form-root" localStatus="ok" onValidityChange={setValidity}>
  <Container name="patient" localStatus={patientStatus()} />
  <Container name="prescriber" localStatus={prescriberStatus()} />
  <Container name="medication" localStatus={medicationStatus()} />
</Container>

<Button disabled={!canSubmit()}>Submit</Button>

Conditional visibility — showWhen

Use showWhen to show a container only when a sibling's validity state meets a condition. The predicate receives the parent's ContainerValidityState, so all siblings are reachable via parentState.children.

When hidden, the container is fully unmounted — it unregisters from its parent and contributes nothing to the parent's derived status. When it re-appears it re-registers as pending and begins propagating normally.

<Container name="prescription-root">
  <Container name="patient" localStatus={patientStatus()} />

  {/* Only appears — and only counts toward root validity — when patient has errors */}
  <Container
    name="override-reason"
    localStatus={overrideStatus()}
    showWhen={(p) => p.children.get('patient')?.status === 'error'}
  >
    <TextInput placeholder="Reason for override" />
  </Container>
</Container>

The predicate can inspect any field of the parent state, including nested children and status values at any severity:

// Show if any direct child has an error
showWhen={(p) => [...p.children.values()].some(c => c.status === 'error')}

// Show only when the parent itself is fully valid
showWhen={(p) => p.status === 'ok'}

Note: showWhen has no effect on root containers (containers with no parent Container ancestor) — they are always rendered.

Locking — locked

Use locked to keep a container visible but non-interactive. When true, a semi-transparent overlay is rendered over the container and the children are marked inert — all mouse, keyboard, and focus interaction is blocked, including elements not reached by the overlay (e.g. via tab navigation). Validity state is unaffected.

const [signing, setSigning] = createSignal<Status>('pending');

<Container name="prescription">
  <Container name="patient"     localStatus={patientStatus()}  locked={signing() === 'ok'} />
  <Container name="medication"  localStatus={medicationStatus()} locked={signing() === 'ok'} />
  <Container name="signing"     localStatus={signing()}>
    {/* completing this locks the rest of the form */}
  </Container>
</Container>

Customise the overlay appearance and provide an icon via overlayStyle and overlayContent:

<Container
  name="patient"
  localStatus={patientStatus()}
  locked={isSigned()}
  overlayStyle={{ 'background-color': 'rgba(0, 0, 0, 0.08)' }}
  overlayContent={<LockIcon style={{ position: 'absolute', top: '0.5rem', right: '0.5rem' }} />}
/>

Note: locked and showWhen are independent — a container can be conditionally shown and locked at the same time. Validity state always propagates regardless of either.

Notes

  • Containers batch all register / unregister / update calls from a single synchronous frame into one reactive notification — large mounts do not cause N separate re-renders.
  • Two levels of nesting require two microtask flushes to fully propagate; this is automatic in a running Solid app but relevant to know when writing tests.
  • name defaults to a Solid createUniqueId() value when omitted — only provide it when the key must be stable or addressable via children maps.
  • A container hidden by showWhen is fully unmounted and does not influence its parent's derived status. It re-registers (as pending) when it becomes visible again.

Slider

A fully accessible, single-value or dual-thumb range slider primitive. Supports tick marks, value tooltips, annotation ranges, and both horizontal and vertical orientations.

Props

Core

| Prop | Type | Default | Description | |---|---|---|---| | value | number \| [number, number] | — | Controlled value. Pass a [low, high] tuple to activate range (two-thumb) mode. | | defaultValue | number \| [number, number] | — | Initial value for uncontrolled usage. Pass a tuple for range mode. | | min | number | 0 | Minimum value. | | max | number | 100 | Maximum value. | | step | number | 1 | Increment snapped to on drag and keyboard movement. | | size | 'sm' \| 'md' \| 'lg' | 'md' | Controls track thickness, thumb size, and mark size. | | orientation | 'horizontal' \| 'vertical' | 'horizontal' | Track direction. For vertical sliders set an explicit height on the wrapper. | | disabled | boolean | — | Prevents interaction and dims the slider. | | invalid | boolean | — | Applies danger styling to the thumb. | | required | boolean | — | Forwarded to the hidden input for form validation. | | id | string | — | Applied to the first thumb element. | | name | string | — | Emits hidden <input> elements for form submission. Range mode emits name[0] and name[1]. | | onChange | (value: SliderValue, e: PointerEvent \| KeyboardEvent) => void | — | Fires on every value change from pointer or keyboard. | | link | FieldLink<any> | — | Syncs state into a createFieldLink() channel so linked labels observe it. |

Marks

| Prop | Type | Default | Description | |---|---|---|---| | marks | boolean \| SliderMark[] | — | true auto-generates a mark at every step (capped at 200). Pass an array for explicit positions with optional labels. |

SliderMark:

| Field | Type | Description | |---|---|---| | value | number | Position on the value axis. | | label | string | Optional text label rendered beside the dot. |

Tooltip

| Prop | Type | Default | Description | |---|---|---|---| | tooltip | boolean \| ((value: number) => JSX.Element) | — | Shows a tooltip above each thumb on focus and drag. true displays the raw number. Pass a function to render custom content. |

Accessibility

| Prop | Type | Description | |---|---|---| | aria-label | string | Accessible label for the thumb(s). | | aria-labelledby | string | ID of an external label element. | | aria-valuetext | string \| ((value: number) => string) | Custom screen-reader announcement instead of the raw number. In range mode pass a function to produce different text per thumb. |

Styling

All style props accept inline JSX.CSSProperties objects and/or a class string. They are spread last onto the element, so any property can be overridden.

| Prop | Targets | |---|---| | style / class | Outer wrapper <div> | | trackStyle / trackClass | Track bar | | fillStyle / fillClass | Filled region | | thumbStyle / thumbClass | Every thumb | | markStyle / markClass | Every mark dot |

Annotation ranges

| Prop | Type | Default | Description | |---|---|---|---| | ranges | SliderRange[] | — | Coloured bands overlaid on the track to mark named value zones. | | rangesExploded | boolean | false | When true, bars separate from the track and stack alongside it so overlapping ranges can be compared side-by-side. Animates between collapsed and exploded states. | | rangesStackDirection | 'above' \| 'below' | 'above' | Default stacking side when rangesExploded is true. For vertical sliders 'above' maps to the left and 'below' to the right. |

SliderRange fields:

| Field | Type | Default | Description | |---|---|---|---| | min | number | — | Start of the range in value-space. | | max | number | — | End of the range in value-space. | | color | string | warningSubtle token | Background colour of the bar. | | thickness | string \| number | — | Bar thickness (height for horizontal, width for vertical). A number is treated as px. When omitted the bar matches the track thickness. The bar is automatically centred on the track axis when a custom thickness is set. | | layer | 'front' \| 'back' | 'front' | 'front' draws the bar on top of the fill; 'back' draws it beneath the fill so the fill overlaps the bar. | | label | string | — | Text label rendered adjacent to the bar. | | labelAlign | 'left' \| 'right' | 'left' | Which edge the label anchors to. Horizontal: left/right edge. Vertical: top/bottom edge. | | stackDirection | 'above' \| 'below' | slider default | Which side this bar stacks toward when rangesExploded is true. Falls back to the slider-level rangesStackDirection, then 'above'. | | style / class | JSX.CSSProperties / string | — | Styles applied to the bar element. | | labelStyle / labelClass | JSX.CSSProperties / string | — | Styles applied to the label element. |

Examples

Uncontrolled

<Slider defaultValue={40} />

Controlled

const [volume, setVolume] = createSignal(60);

<Slider
  value={volume()}
  onChange={(v) => setVolume(v as number)}
/>

Range mode (two thumbs)

Pass a [low, high] tuple to either value or defaultValue.

const [range, setRange] = createSignal<[number, number]>([20, 75]);

<Slider
  value={range()}
  onChange={(v) => setRange(v as [number, number])}
/>

Sizes

<Slider defaultValue={50} size="sm" />
<Slider defaultValue={50} size="md" />  {/* default */}
<Slider defaultValue={50} size="lg" />

Vertical orientation

Set an explicit height on the wrapper (or the slider itself via style).

<Slider
  defaultValue={50}
  orientation="vertical"
  style={{ height: '200px' }}
/>

Tick marks — auto

Generates a dot at every step. Capped at 200 marks to avoid visual noise.

<Slider defaultValue={3} min={1} max={5} step={1} marks />

Tick marks — explicit with labels

<Slider
  defaultValue={50}
  marks={[
    { value: 0,   label: 'Low' },
    { value: 50,  label: 'Mid' },
    { value: 100, label: 'High' },
  ]}
/>

Value tooltip

{/* Raw number */}
<Slider defaultValue={42} tooltip />

{/* Custom content */}
<Slider
  defaultValue={42}
  tooltip={(v) => <span>{v} kg</span>}
/>

Disabled and invalid states

<Slider defaultValue={50} disabled />
<Slider defaultValue={50} invalid />

Styling individual parts

All style props are spread last so any CSS property can be overridden.

<Slider
  defaultValue={50}
  class="my-slider"
  trackStyle={{ 'background-color': '#e5e7eb' }}
  fillStyle={{ background: 'linear-gradient(to right, #6366f1, #8b5cf6)' }}
  thumbStyle={{ 'border-color': '#6366f1', 'background-color': 'white' }}
  markStyle={{ 'background-color': '#6366f1' }}
/>

Annotation ranges — basic

<Slider
  defaultValue={60}
  ranges={[
    { min: 0,  max: 40, color: '#86efac', label: 'Normal' },
    { min: 40, max: 70, color: '#fde68a', label: 'Elevated' },
    { min: 70, max: 100, color: '#fca5a5', label: 'High' },
  ]}
/>

Range bar behind the fill

Use layer: 'back' so the fill renders on top of the annotation bar. Combine with thickness to make the bar thinner so it peeks out at the edges.

<Slider
  defaultValue={50}
  ranges={[{
    min: 30, max: 80,
    color: '#fde68a',
    layer: 'back',
    thickness: 3,       // 3px, centred on the track axis
  }]}
/>

Custom thickness without layer change

The bar is centred on the track axis automatically when thickness is set.

<Slider
  defaultValue={50}
  ranges={[{
    min: 20, max: 60,
    color: '#a5b4fc',
    thickness: '50%',   // any CSS length
  }]}
/>

Exploded ranges

When rangesExploded is true, overlapping bars separate from the track and stack alongside it so all zones remain legible simultaneously.

<Slider
  defaultValue={50}
  rangesExploded
  rangesStackDirection="above"
  ranges={[
    { min: 10, max: 50, color: '#fde68a', label: 'Zone A', stackDirection: 'above' },
    { min: 30, max: 80, color: '#86efac', label: 'Zone B', stackDirection: 'below' },
  ]}
/>

Form integration

Emits hidden inputs for native form submission. Range mode uses name[0] / name[1].

<Slider name="dose" defaultValue={50} />
<Slider name="dose" defaultValue={[20, 80]} />

Field link

Syncs invalid, disabled, and required into a createFieldLink() channel so a paired <Label> reflects the field state.

const link = createFieldLink();

<Label link={link}>Dosage</Label>
<Slider link={link} invalid={hasError()} required />

Accessible value text

{/* Static string */}
<Slider defaultValue={42} aria-valuetext="42 kilograms" aria-label="Weight" />

{/* Dynamic — different text per thumb in range mode */}
<Slider
  defaultValue={[20, 80]}
  aria-valuetext={(v) => `${v} mg`}
  aria-label="Dose range"
/>

Textarea with Highlighting — TextareaHighlighted

A contenteditable div that looks and behaves like Textarea but accepts a renderHtml callback to inject highlighted markup while preserving the cursor position. The stored value is always plain text — the caller is responsible for converting it to HTML.

Props

| Prop | Type | Default | Description | |---|---|---|---| | value | string | — | Controlled plain-text value. | | onInput | (value: string) => void | — | Fires on every keystroke with the current plain-text content. | | onChange | (value: string) => void | — | Fires on the native change event with the current plain-text content. | | onFocus | (e: FocusEvent) => void | — | Focus handler. | | onBlur | (e: FocusEvent) => void | — | Blur handler. | | renderHtml | (value: string) => string | — | Converts plain text to an HTML string for display. Called after every input event. If omitted, the text is rendered with HTML entities escaped. | | placeholder | string | — | Exposed via aria-placeholder. | | size | 'sm' \| 'md' \| 'lg' | 'md' | Same size scale as Textarea. | | resize | 'none' \| 'both' \| 'horizontal' \| 'vertical' | 'vertical' | CSS resize behaviour. | | disabled | boolean | — | Sets contenteditable="false" and applies disabled styles. | | invalid | boolean | — | Applies danger border and focus ring. | | required | boolean | — | Exposed via aria-required. | | id | string | — | Forwarded to the root element. | | link | FieldLink<any> | — | Syncs invalid, disabled, and required into a createFieldLink() channel so linked labels observe the state. | | style | JSX.CSSProperties | — | Merged on top of composed styles. | | class | string | — | Applied to the root element. |

Uncontrolled — syntax highlighting as you type

Provide only renderHtml. The component owns the text state internally and calls renderHtml after every keystroke.

<TextareaHighlighted
  renderHtml={(text) =>
    text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/@(\w+)/g,
      '<mark style="background: lightyellow">@$1</mark>'
    )
  }
  placeholder="Type an @mention…"
/>

Controlled — driving value from a signal

Pair value with onInput. The component re-renders the HTML whenever the signal changes, but only when the text content actually differs — so the cursor is never disturbed on a same-value update.

const [text, setText] = createSignal('');

<TextareaHighlighted
  value={text()}
  onInput={setText}
  renderHtml={(value) => highlight(value)}
  placeholder="Start typing…"
/>

Writing a renderHtml function

renderHtml receives plain text and must return a valid HTML string. You are responsible for escaping any characters that should not be interpreted as HTML before wrapping them in markup.

function highlight(value: string): string {
  // 1. Escape first so your markup is not itself escaped.
  const escaped = value
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;');

  // 2. Apply markup to the escaped string.
  return escaped
    .replace(/\*([^*]+)\*/g, '<strong>*$1*</strong>')
    .replace(/(@\w+)/g, '<mark>$1</mark>');
}

Important: always escape &, <, and > before you apply pattern replacements — otherwise any < the user types will be interpreted as an HTML tag once you inject the result as innerHTML.

Paste handling

Paste is intercepted automatically. Rich content is stripped to plain text before insertion so pasted HTML never pollutes the document or the value.

Using with a field link

TextareaHighlighted supports the same createFieldLink integration as Textarea:

const link = createFieldLink();

<Label link={link}>Notes</Label>
<TextareaHighlighted link={link} invalid={hasError()} required />

Contributing

  • Keep all code domain-agnostic
  • No context, store, or SDK usage in this package
  • Follow the architectural principles in tada.md