WildflowerJS Reactive JS, No BS*

A no-build reactive JavaScript framework, rooted in the web platform.
No build step. No dependencies. No lock-in.

<script src="wildflower.min.js"></script> ...and start building.

Back to Basics

The code you write is 100% web standard code. HTML stays HTML. JavaScript stays JavaScript. CSS stays CSS. No JSX, no templating language, no custom syntax to learn. If you know the web platform, you already know how to use this.

WildflowerJS extends the web platform. It doesn't replace it.

Your Development Simplified

Because you develop with 100% web standards, every tool in your existing chain already understands the code: IDE, browser DevTools, linter, formatter, screen reader, SEO crawler. Nothing to install, no custom file types, no sourcemaps. Save the file, refresh, and your change is live.

Just be a web developer.

Batteries Included: One Mental Model

Router, SSR, stores, computed properties, two-way binding, event modifiers, data pools, and TypeScript types, all built in, all speaking the same language. Learn data-bind once and you know binding everywhere: lists, pools, stores, forms. There's no five-library stack to keep in sync.

One script tag. Everything you need.

<div data-component="counter">
  <span data-bind="count"></span>
  <button data-action="increment">
    +1
  </button>
</div>

<script>
wildflower.component('counter', {
  state: { count: 0 },
  increment() { this.count++ }
})
</script>

How It Works

data-bind connects state to the DOM.

data-action connects events to methods.

this.count++ triggers a precise DOM update.

Mutate state. The DOM updates.

Two Reactivity Modes

data-list for automatic reactivity: mutate state, DOM updates. data-pool for explicit control: plain objects, zero proxy overhead, you say what changed.

Same template syntax. Different performance profile. From interactive forms to per-frame particle systems. You choose the right tradeoff for the job.

Try it. Right-click, inspect this demo. Every dot is a real DOM element.

See full demo →

* Build Step

Zero Toolchain

Modern frameworks ask you to install a compiler, a bundler, a package manager, hundreds of fragile transitive dependencies, and a framework-specific file format, before you write a single line of your application.

WildflowerJS was built starting from a single principle: no build step, no tooling. Ever.

WildflowerJS asks you to add a script tag.

There's no CLI scaffolding step, no config files, no .vue/.jsx/.svelte source format. You don't debug through sourcemaps or wait on a build pipeline. Your project has zero dependencies.

Performance isn't a tradeoff. Build steps optimize bundle delivery, not the runtime work that follows it. WildflowerJS writes directly to the DOM, with no virtual DOM or reconciliation pass between state change and update, so it doesn't need a build step to be fast.

The framework is full-featured without the toolchain: router, SSR, stores, computed properties, transitions, pools. You don't need a toolchain to use any of it.

my-app/
  index.html
  app.js
  style.css
  wildflower.min.js

That's the entire project. No package.json.
No node_modules. No config files. Ship it.

Zero Install. Zero Attack Surface.

Every dependency you install is trust extended to a maintainer you've never met, running scripts on your dev machine and in your CI. A typical React + Vite + UI‑lib setup pulls in 300+ transitive packages before you write a feature.

Each one is a potential intrusion vector. NPM worms, OAuth chains compromising deploy platforms, postinstall hijacking: the supply chain is now where production code gets compromised, not the deploy. And signing isn't a backstop: Mini Shai‑Hulud (May 2026) compromised 170+ packages whose malicious versions carried valid SLSA Build Level 3 provenance, because the attestation came from build infrastructure the worm had already taken over.

WildflowerJS users don't have this attack surface, by construction. There is no npm install, no postinstall script, no transitive package graph. The framework is one file you copy or pin by hash.

As of v1.1, the same holds for building the framework itself. WildflowerJS bundles with a vendored rollup and terser pipeline pulled as three SHA‑512‑pinned tarballs: no npm install, no transitive packages, no postinstall scripts in the build path. The entire toolchain is three files you verify by hash.

Zero dependencies is the absence of a problem the rest of the industry has not properly addressed.

A typical React/Vue project:

  npm install
  ├── hundreds of packages
  ├── from hundreds of maintainers
  ├── postinstall scripts run on install
  └── tens to hundreds of MB of transitive code

WildflowerJS:

  <script src="wildflower.min.js"></script>
  └── 1 file.
      No transitive dependencies.

Zero Lock-in

WildflowerJS works with the DOM, not instead of it. There's no virtual DOM intercepting your code and no compiler rewriting your markup. The render cycle is yours.

That means Leaflet, DataTables, Chart.js, D3, Three.js, any library that touches the DOM, just works. No wrapper packages or framework-specific escape hatches required. Drop in a script tag and use it.

Because your code is standard HTML and JavaScript, you're never locked in. Your skills transfer and your code is more portable. If you outgrow the framework, your knowledge doesn't expire.

This also means your "ecosystem" is all of the world of vanilla JS. Without compromises or hacks.

<!-- Use any library directly -->
<div data-component="map-view">
  <div id="map" style="height: 400px"></div>
</div>
wildflower.component('map-view', {
  state: { lat: 51.505, lng: -0.09 },
  init() {
    // Leaflet works as-is. No wrappers.
    this._map = L.map('map')
      .setView([this.lat, this.lng], 13);
    L.tileLayer('https://{s}.tile.osm.org'
      + '/{z}/{x}/{y}.png').addTo(this._map);
  }
})

Precise Reactivity

When you write this.count++, WildflowerJS updates the single DOM node bound to count. Nothing else is touched. There's no tree diffing or reconciliation pass to figure that out.

This isn't a tradeoff. You get fine-grained updates and a simple mental model. Change a property, the bound element updates. That's the entire reactivity model.

Other frameworks ask you to learn signals, accessors, memos, effects, and subscription lifecycles to achieve what WildflowerJS does with a property assignment.

wildflower.component('dashboard', {
  state: {
    users: 1420,
    status: 'healthy'
  },
  computed: {
    summary() {
      return this.users + ' users, ' + this.status;
    }
  },
  refresh() {
    this.users = 1421;
    // Only the elements bound to 'users'
    // and 'summary' update. Everything
    // else on the page is untouched.
  }
})

One Reactivity Model. Everywhere.

Components, Stores, and Plugins all share the same reactive foundation. State, computed properties, and methods work identically no matter where they live. Learn it once, it works the same way in a UI component, a global store, or a framework plugin.

Other frameworks make you learn a different system for each layer. React components use hooks, but stores need Redux or Zustand, which are completely different APIs. Vue components use reactive data, but Pinia stores have their own patterns. Every layer is a new mental model.

In WildflowerJS, there's one model. A store is a component without a template. A plugin is an entity that extends the framework itself, adding directives, lifecycle hooks, and services. The same this.count++ triggers the same reactivity everywhere.

This unlocks patterns other frameworks can't express. A store can run headless physics simulations with tick(), feeding data into a component that renders it through a pool, all using the same reactive primitives, no glue code required.

// Component: reactive UI
wildflower.component('cart', {
  state: { items: [] },
  computed: {
    total() { return this.items.length; }
  }
})

// Store: global shared state
wildflower.store('user', {
  state: { name: '', role: 'guest' },
  computed: {
    isAdmin() { return this.role === 'admin'; }
  }
})

// Plugin: extends the framework
wildflower.plugin({
  name: 'notifications',
  state: { items: [], unreadCount: 0 },
  computed: {
    hasUnread() { return this.unreadCount > 0; }
  },
  add(msg) { this.items.push(msg); this.unreadCount++; }
})
// Access globally: wildflower.$notifications.add(...)

// Same state. Same computed. Same methods.

Data Pools

Every framework wraps collection items in reactive proxies, whether the item needs it or not. WildflowerJS gives you a choice: data-list for push reactivity (automatic), data-pool for pull reactivity (explicit control, zero proxy overhead).

Pools render plain objects with the same template syntax as lists. Mutate the object, call markDirty(), and only that item updates. Full CRUD, selection, bulk operations, all faster than the push-reactive path.

And because pools use pull-based rendering, they scale to simulations, games, particle systems, and data visualizations at native frame rate. Use cases that would choke a virtual DOM. No other framework has anything like this.

<div data-component="user-table">
  <tbody data-pool="users" data-key="id">
    <template>
      <tr>
        <td data-bind="name"></td>
        <td data-bind="status"
            data-bind-class="status === 'active'
              ? 'badge success'
              : 'badge inactive'"></td>
      </tr>
    </template>
  </tbody>
</div>
wildflower.component('user-table', {
  pools: { users: {} },

  init() {
    // Populate: plain objects, no proxies
    data.forEach(u => this.pools.users.add(u));
  },

  // Optional: add tick() and the same pool
  // renders every frame. Same template, same
  // data, different rendering frequency.
  // That's the only difference between a
  // display table and a particle system.
})

Built for AI-Assisted Development

Because WildflowerJS is standard HTML and JavaScript, AI code assistants already know how to write it. There's no custom syntax to hallucinate or compiler quirks to work around. The code an AI generates runs exactly as written, with no build step between generation and execution.

We go further. WildflowerJS ships an AI-optimized reference page with patterns, anti-patterns, and examples designed for code generation context windows. Our llms.txt file follows the llms.txt convention for machine-readable documentation.

And for structured app generation, our Universal App Manifest lets you describe an entire application as a JSON schema (components, state, computed properties, methods, templates) and have an AI generate the working code from the manifest, mediated through framework-specific idiom files.

You: "Build me a todo app with
WildflowerJS"

AI reads llms.txt or ai-assistant.html
     ↓
Generates standard HTML + JS
     ↓
<div data-component="todo-app">
  <input data-model="newItem">
  <button data-action="addItem">
    Add
  </button>
  <ul data-list="items">
    <template>
      <li data-bind="text"></li>
    </template>
  </ul>
</div>
     ↓
Open in your browser. It works, and you can read and understand the code.

Basic Form Handling

WildflowerJS provides two-way data binding between form inputs and component state through data-model attributes, with automatic type conversion for numbers, booleans, and dates.

💡 Key Concept: WildflowerJS provides seamless two-way data binding between form inputs and component state, making form handling intuitive and reactive.

Basic Form Binding

Use data-model for two-way binding between form inputs and component state:

<div data-component="basic-form">
    <p class="text-muted">Demonstrates two-way data binding with various form input types.</p>
    
    <div class="row">
        <div class="col-md-6">
            <h5>Form Inputs</h5>
            <form data-action="handleSubmit">
                <div class="mb-3">
                    <label class="form-label">Name: *</label>
                    <input type="text" data-model="form.name" class="form-control" 
                           placeholder="Enter your full name" required>
                </div>
                
                <div class="mb-3">
                    <label class="form-label">Email: *</label>
                    <input type="email" data-model="form.email" class="form-control" 
                           placeholder="your.email@example.com" required>
                </div>
                
                <div class="mb-3">
                    <label class="form-label">Age:</label>
                    <input type="number" data-model="form.age" class="form-control" 
                           min="1" max="120" placeholder="Your age">
                </div>
                
                <div class="mb-3">
                    <label class="form-label">Profession:</label>
                    <select data-model="form.profession" class="form-select">
                        <option value="">Select your profession</option>
                        <option value="developer">Software Developer</option>
                        <option value="designer">Designer</option>
                        <option value="manager">Manager</option>
                        <option value="student">Student</option>
                        <option value="other">Other</option>
                    </select>
                </div>
                
                <div class="mb-3">
                    <label class="form-label">Bio:</label>
                    <textarea data-model="form.bio" class="form-control" rows="3" 
                              placeholder="Tell us a little about yourself..."></textarea>
                </div>
                
                <div class="mb-3">
                    <div class="form-check">
                        <input type="checkbox" data-model="form.subscribe" id="subscribe" class="form-check-input">
                        <label for="subscribe" class="form-check-label">
                            Subscribe to our newsletter
                        </label>
                    </div>
                    <div class="form-check">
                        <input type="checkbox" data-model="form.notifications" id="notifications" class="form-check-input">
                        <label for="notifications" class="form-check-label">
                            Allow push notifications
                        </label>
                    </div>
                </div>
                
                <div class="d-flex gap-2">
                    <button type="submit" class="btn btn-primary">
                        Submit Form
                    </button>
                    <button type="button" data-action="resetForm" class="btn btn-secondary">
                        Reset
                    </button>
                </div>
            </form>
        </div>
        
        <div class="col-md-6">
            <h5>Live Data Preview</h5>
            <div class="card h-100">
                <div class="card-body">
                    <div class="mb-2">
                        <strong>Name:</strong> 
                        <span data-bind="form.name || 'Not provided'" 
                              data-bind-class="form.name ? 'text-success' : 'text-muted'"></span>
                    </div>
                    <div class="mb-2">
                        <strong>Email:</strong> 
                        <span data-bind="form.email || 'Not provided'"
                              data-bind-class="form.email ? 'text-success' : 'text-muted'"></span>
                    </div>
                    <div class="mb-2">
                        <strong>Age:</strong> 
                        <span data-bind="form.age || 'Not provided'"
                              data-bind-class="form.age ? 'text-success' : 'text-muted'"></span>
                        <span data-show="form.age" class="text-muted small"> years old</span>
                    </div>
                    <div class="mb-2">
                        <strong>Profession:</strong> 
                        <span data-bind="form.profession || 'Not selected'"
                              data-bind-class="form.profession ? 'text-success' : 'text-muted'"></span>
                    </div>
                    <div class="mb-2">
                        <strong>Bio:</strong> 
                        <div data-bind="form.bio || 'No bio provided'"
                             data-bind-class="form.bio ? 'text-success' : 'text-muted'"
                             style="white-space: pre-wrap; font-size: 0.9em;"></div>
                    </div>
                    <div class="mb-2">
                        <strong>Newsletter:</strong> 
                        <span data-bind="form.subscribe ? '✅ Subscribed' : '❌ Not subscribed'"
                              data-bind-class="form.subscribe ? 'text-success' : 'text-muted'"></span>
                    </div>
                    <div class="mb-2">
                        <strong>Notifications:</strong> 
                        <span data-bind="form.notifications ? '✅ Enabled' : '❌ Disabled'"
                              data-bind-class="form.notifications ? 'text-success' : 'text-muted'"></span>
                    </div>
                    
                    <hr>
                    <div class="small text-muted">
                        <strong>Form Completeness:</strong> 
                        <span data-bind="completionPercentage"></span>%
                        <div class="progress mt-1" style="height: 12px;">
                            <div class="progress-bar" data-bind-class="progressColorClass"
                                 data-bind-style="{ width: completionPercentage + '%' }"></div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
    
    <div class="mt-3" data-show="submissionResult">
        <div class="alert alert-success">
            <h6>✅ Form Submitted Successfully!</h6>
            <p>Here's the data that was captured:</p>
            <pre class="small bg-light p-2 rounded"><code data-bind="submissionResult"></code></pre>
        </div>
    </div>
</div>
wildflower.component('basic-form', {
    state: {
        form: {
            name: '',
            email: '',
            age: '',
            profession: '',
            bio: '',
            subscribe: false,
            notifications: false
        },
        submissionResult: ''
    },
    
    computed: {
        // Calculate form completion percentage
        completionPercentage() {
            const fields = ['name', 'email', 'age', 'profession', 'bio']
            const completed = fields.filter(field => this.form[field]).length
            return Math.round((completed / fields.length) * 100)
        },
        
        // Dynamic progress bar color based on completion
        progressColorClass() {
            const percentage = this.completionPercentage
            if (percentage >= 80) return 'bg-success'
            if (percentage >= 50) return 'bg-warning'
            return 'bg-danger'
        },
        
    },
    
    // Handle form submission
    handleSubmit(event, element) {
        event.preventDefault()
        
        // Create a clean copy of form data for submission
        const submissionData = {
            ...this.form,
            submittedAt: new Date().toISOString(),
            completeness: `${this.completionPercentage}%`
        }
        
        // Simulate form submission
        this.submissionResult = JSON.stringify(submissionData, null, 2)
        
        // Clear result after 8 seconds
        setTimeout(() => {
            this.submissionResult = ''
        }, 8000)
    },
    
    // Reset form to initial state
    resetForm() {
        this.form = {
            name: '',
            email: '',
            age: '',
            profession: '',
            bio: '',
            subscribe: false,
            notifications: false
        }
        this.submissionResult = ''
    },
    
})
Live Preview

Form Input Types

WildflowerJS supports all HTML form input types with automatic type conversion and proper data binding:

<div data-component="input-types-demo">
    <p class="text-muted">Experience how WildflowerJS handles various HTML input types with automatic data binding and type conversion.</p>
    
    <div class="row">
        <div class="col-md-6">
            <h5>Text-Based Inputs</h5>
            
            <div class="mb-3">
                <label class="form-label">Text:</label>
                <input type="text" data-model="inputs.text" class="form-control" placeholder="Enter any text">
            </div>
            
            <div class="mb-3">
                <label class="form-label">Password:</label>
                <input type="password" data-model="inputs.password" class="form-control" placeholder="Enter password">
            </div>
            
            <div class="mb-3">
                <label class="form-label">Email:</label>
                <input type="email" data-model="inputs.email" class="form-control" placeholder="user@example.com">
            </div>
            
            <div class="mb-3">
                <label class="form-label">URL:</label>
                <input type="url" data-model="inputs.url" class="form-control" placeholder="https://example.com">
            </div>
            
            <div class="mb-3">
                <label class="form-label">Search:</label>
                <input type="search" data-model="inputs.search" class="form-control" placeholder="Search terms...">
            </div>
            
            <div class="mb-3">
                <label class="form-label">Tel:</label>
                <input type="tel" data-model="inputs.tel" class="form-control" placeholder="(555) 123-4567">
            </div>
        </div>
        
        <div class="col-md-6">
            <h5>Numeric & Date Inputs</h5>
            
            <div class="mb-3">
                <label class="form-label">Number:</label>
                <input type="number" data-model="inputs.number" class="form-control" min="0" max="100" step="1">
            </div>
            
            <div class="mb-3">
                <label class="form-label">Range (<span data-bind="inputs.range"></span>):</label>
                <input type="range" data-model="inputs.range" class="form-range" min="0" max="100" step="5">
            </div>
            
            <div class="mb-3">
                <label class="form-label">Date:</label>
                <input type="date" data-model="inputs.date" class="form-control">
            </div>
            
            <div class="mb-3">
                <label class="form-label">Time:</label>
                <input type="time" data-model="inputs.time" class="form-control">
            </div>
            
            <div class="mb-3">
                <label class="form-label">Date/Time:</label>
                <input type="datetime-local" data-model="inputs.datetime" class="form-control">
            </div>
            
            <div class="mb-3">
                <label class="form-label">Color:</label>
                <input type="color" data-model="inputs.color" class="form-control form-control-color">
            </div>
        </div>
    </div>
    
    <div class="row">
        <div class="col-md-6">
            <h5>Selection Inputs</h5>
            
            <div class="mb-3">
                <label class="form-label">Select:</label>
                <select data-model="inputs.select" class="form-select">
                    <option value="">Choose category...</option>
                    <option value="frontend">Frontend Development</option>
                    <option value="backend">Backend Development</option>
                    <option value="fullstack">Full Stack Development</option>
                    <option value="mobile">Mobile Development</option>
                    <option value="devops">DevOps</option>
                </select>
            </div>
            
            <div class="mb-3">
                <label class="form-label">Experience Level:</label>
                <div class="form-check">
                    <input type="radio" data-model="inputs.experience" value="beginner" id="beginner" name="experience" class="form-check-input">
                    <label for="beginner" class="form-check-label">Beginner (0-2 years)</label>
                </div>
                <div class="form-check">
                    <input type="radio" data-model="inputs.experience" value="intermediate" id="intermediate" name="experience" class="form-check-input">
                    <label for="intermediate" class="form-check-label">Intermediate (3-5 years)</label>
                </div>
                <div class="form-check">
                    <input type="radio" data-model="inputs.experience" value="advanced" id="advanced" name="experience" class="form-check-input">
                    <label for="advanced" class="form-check-label">Advanced (5+ years)</label>
                </div>
            </div>
            
            <div class="mb-3">
                <label class="form-label">Technologies:</label>
                <div class="form-check">
                    <input type="checkbox" data-model="inputs.technologies.javascript" id="js" class="form-check-input">
                    <label for="js" class="form-check-label">JavaScript</label>
                </div>
                <div class="form-check">
                    <input type="checkbox" data-model="inputs.technologies.python" id="py" class="form-check-input">
                    <label for="py" class="form-check-label">Python</label>
                </div>
                <div class="form-check">
                    <input type="checkbox" data-model="inputs.technologies.java" id="java" class="form-check-input">
                    <label for="java" class="form-check-label">Java</label>
                </div>
                <div class="form-check">
                    <input type="checkbox" data-model="inputs.technologies.react" id="react" class="form-check-input">
                    <label for="react" class="form-check-label">React</label>
                </div>
            </div>
        </div>
        
        <div class="col-md-6">
            <h5>Live Data Preview</h5>
            <div class="card h-100">
                <div class="card-body">
                    <div class="small" style="max-height: 400px; overflow-y: auto;">
                        <pre><code data-bind="inputsJson"></code></pre>
                    </div>
                    <hr>
                    <div class="small text-muted">
                        <strong>Data Types:</strong><br>
                        Number: <span data-bind="numberType"></span><br>
                        Range: <span data-bind="rangeType"></span><br>
                        Date: <span data-bind="dateType"></span><br>
                        Color: <span data-bind="colorType"></span>
                    </div>
                </div>
            </div>
        </div>
    </div>
    
    <div class="mt-3">
        <button type="button" data-action="clearAll" class="btn btn-secondary">
            Clear All
        </button>
    </div>
</div>
wildflower.component('input-types-demo', {
    state: {
        inputs: {
            text: '',
            password: '',
            email: '',
            url: '',
            search: '',
            tel: '',
            number: 0,
            range: 50,
            date: '',
            time: '',
            datetime: '',
            color: '#3498db',
            select: '',
            experience: '',
            technologies: {
                javascript: false,
                python: false,
                java: false,
                react: false
            }
        },
        typeInfo: false
    },
    
    computed: {
        inputsJson() {
            return JSON.stringify(this.inputs, null, 2)
        },
        
        numberType() {
            return typeof this.inputs.number + ' (' + this.inputs.number + ')'
        },
        
        rangeType() {
            return typeof this.inputs.range + ' (' + this.inputs.range + ')'
        },
        
        dateType() {
            return typeof this.inputs.date + ' (' + (this.inputs.date || 'empty') + ')'
        },
        
        colorType() {
            return typeof this.inputs.color + ' (' + this.inputs.color + ')'
        }
    },
    
    clearAll() {
        this.inputs = {
            text: '',
            password: '',
            email: '',
            url: '',
            search: '',
            tel: '',
            number: 0,
            range: 50,
            date: '',
            time: '',
            datetime: '',
            color: '#3498db',
            select: '',
            experience: '',
            technologies: {
                javascript: false,
                python: false,
                java: false,
                react: false
            }
        }
    }
})
Live Preview
Input Type Handling

WildflowerJS automatically converts input values to appropriate JavaScript types:

  • number, range: Converted to JavaScript numbers
  • checkbox: Converted to boolean values
  • date, time, datetime-local: Remain as strings in ISO format
  • text, email, url, etc.: Remain as strings

Form Validation

WildflowerJS uses the browser's native HTML5 Constraint Validation API for standard rules (required, minlength, pattern, type="email", etc.) and adds a small set of framework attributes for triggers, custom rules, and inline error rendering.

Built-in validation with data-validate-on

Add data-validate-on to the <form> element along with novalidate. The novalidate attribute is required. Without it, the browser's native tooltip popups fire first and prevent the framework's inline error messages from rendering.

Trigger values are a comma-separated list:

  • data-validate-on="submit": validate the entire form on submit. The configured data-action on the form runs only when validation passes.
  • data-validate-on="blur": validate each input as it loses focus. Updates the matching data-error-for element and toggles the .invalid class on the input. No manual focusout listener needed.
  • data-validate-on="submit,blur": both. Per-field feedback as the user tabs through, plus a whole-form pass on submit.

Template pattern

<form data-validate-on="submit,blur" data-action="handleSubmit" novalidate>
    <label>Email
        <input type="email" required data-model="form.email">
    </label>
    <span class="error-message" data-error-for="form.email"></span>

    <label>Name
        <input type="text" required minlength="2" data-model="form.name">
    </label>
    <span class="error-message" data-error-for="form.name"></span>

    <button type="submit">Submit</button>
</form>

Notes on the form-level data-action: when data-action is on a <form>, the framework handles the submit event automatically; do not use the submit: prefix. The action method receives (event, formElement) and is called only after validation passes.

Native validation rules

Standard HTML5 input attributes work out of the box; messages come from input.validationMessage (browser-default text, localized):

  • required
  • minlength="N", maxlength="N"
  • type="email", type="url", type="number"
  • pattern="regex"
  • min="N", max="N", step="N"

Framework extensions

Three additional input-level attributes layer on top of native validation:

  • data-validate="/regex/": custom regex check (slash-delimited). Runs after native validation passes.
  • data-validate="number": accepts any value that parses as a float.
  • data-validate="integer": accepts only -?\d+.
  • data-validate-message="...": overrides the default error message for the data-validate rule on that input.
<input data-model="user.code"
       data-validate="/^[A-Z]{3}-\d{4}$/"
       data-validate-message="Code must look like ABC-1234">
<span class="error-message" data-error-for="user.code"></span>

State the framework sets

After every validation pass, the framework writes:

  • The CSS class invalid on each failing input (removed when it passes).
  • this.state.formValid (boolean): whole-form validity after the most recent pass.
  • this.state.validationErrors: { [modelPath]: errorMessage } for currently-failing fields.

You can bind to that state declaratively. For example, disable a submit button until the form is valid:

<button type="submit" data-bind-attr="{ disabled: !formValid }">Submit</button>

Or show a single summary line when any error is present:

<div class="form-summary-error" data-show="!formValid">Please fix the highlighted fields.</div>

Custom validation logic

When you need richer rules (cross-field comparisons, debounced server checks, password-strength meters), write the validation yourself in action handlers. The example below uses blur: and input: action modifiers to run validators as the user interacts:

<div data-component="form-validation">
    <p class="text-muted">Experience real-time validation with custom rules, password strength analysis, and comprehensive error handling.</p>
    
    <form data-action="handleSubmit" class="needs-validation" novalidate>
        <div class="row">
            <div class="col-md-6">
                <div class="mb-3">
                    <label class="form-label">Username: *</label>
                    <input type="text" 
                           data-model="form.username" 
                           data-action="blur:validateUsername input:clearUsernameError"
                           class="form-control"
                           data-bind-class="getUsernameClass"
                           placeholder="Enter username"
                           required>
                    <div class="invalid-feedback" data-bind="errors.username"></div>
                    <div class="form-text">Username must be 3-20 characters, alphanumeric only</div>
                </div>
                
                <div class="mb-3">
                    <label class="form-label">Email: *</label>
                    <input type="email" 
                           data-model="form.email" 
                           data-action="blur:validateEmail"
                           class="form-control"
                           data-bind-class="getEmailClass"
                           placeholder="your.email@example.com"
                           required>
                    <div class="invalid-feedback" data-bind="errors.email"></div>
                    <div class="valid-feedback" data-show="touched.email && form.email && !errors.email">
                        Valid email address!
                    </div>
                </div>
                
                <div class="mb-3">
                    <label class="form-label">Phone: *</label>
                    <input type="tel" 
                           data-model="form.phone" 
                           data-action="blur:validatePhone input:formatPhone"
                           class="form-control"
                           data-bind-class="getPhoneClass"
                           placeholder="(555) 123-4567"
                           required>
                    <div class="invalid-feedback" data-bind="errors.phone"></div>
                    <div class="form-text">US phone number format</div>
                </div>
                
                <div class="mb-3">
                    <label class="form-label">Age: *</label>
                    <input type="number" 
                           data-model="form.age" 
                           data-action="blur:validateAge"
                           class="form-control"
                           data-bind-class="getAgeClass"
                           min="13" max="120" 
                           placeholder="Enter your age"
                           required>
                    <div class="invalid-feedback" data-bind="errors.age"></div>
                    <div class="form-text">Must be between 13 and 120 years old</div>
                </div>
            </div>
            
            <div class="col-md-6">
                <div class="mb-3">
                    <label class="form-label">Password: *</label>
                    <div class="position-relative">
                        <input type="password"
                               id="password-input"
                               data-model="form.password"
                               data-action="input:validatePassword"
                               class="form-control"
                               data-bind-class="getPasswordClass"
                               placeholder="Create a strong password"
                               required>
                        <button type="button" 
                                data-action="togglePasswordVisibility" 
                                class="btn btn-secondary position-absolute end-0 top-0 h-100 px-2" 
                                style="border-left: none; border-radius: 0 0.375rem 0.375rem 0;">
                            <span data-bind="showPassword ? '🙈' : '👁️'"></span>
                        </button>
                    </div>
                    <div class="invalid-feedback" data-bind="errors.password"></div>
                    <div class="mt-2">
                        <div class="d-flex justify-content-between align-items-center">
                            <small class="text-muted">Password strength:</small>
                            <span class="badge" data-bind="passwordStrength" data-bind-class="passwordStrengthClass"></span>
                        </div>
                        <div class="progress mt-1" style="height: 6px;">
                            <div class="progress-bar"
                                 data-bind-class="passwordStrengthColor"
                                 data-bind-style="{ width: passwordStrengthPercent + '%' }"></div>
                        </div>
                    </div>
                </div>
                
                <div class="mb-3">
                    <label class="form-label">Confirm Password: *</label>
                    <input type="password"
                           data-model="form.confirmPassword"
                           data-action="input:validateConfirmPassword"
                           class="form-control"
                           data-bind-class="getConfirmPasswordClass"
                           placeholder="Confirm your password"
                           required>
                    <div class="invalid-feedback" data-bind="errors.confirmPassword"></div>
                    <div class="valid-feedback" data-show="touched.confirmPassword && form.confirmPassword && !errors.confirmPassword">
                        Passwords match!
                    </div>
                </div>
                
                <div class="mb-3">
                    <label class="form-label">Account Type: *</label>
                    <select data-model="form.accountType" 
                            data-action="change:validateAccountType"
                            class="form-select"
                            data-bind-class="getAccountTypeClass"
                            required>
                        <option value="">Select account type...</option>
                        <option value="personal">Personal</option>
                        <option value="business">Business</option>
                        <option value="premium">Premium</option>
                    </select>
                    <div class="invalid-feedback" data-bind="errors.accountType"></div>
                </div>
            </div>
        </div>
        
        <div class="mb-3">
            <div class="form-check">
                <input type="checkbox" 
                       data-model="form.termsAccepted" 
                       data-action="change:validateTerms"
                       id="terms" 
                       class="form-check-input"
                       data-bind-class="getTermsClass"
                       required>
                <label for="terms" class="form-check-label">
                    I accept the <a href="#" data-action="showTerms">terms and conditions</a> *
                </label>
            </div>
            <div class="invalid-feedback" data-bind="errors.terms"></div>
        </div>
        
        <div class="d-flex flex-column flex-sm-row justify-content-between align-items-start align-items-sm-center gap-2 mb-3">
            <button type="submit"
                    class="btn"
                    data-bind-class="submitButtonClass"
                    data-bind-attr="{ disabled: !isFormValid || isSubmitting }">
                <span data-show="isSubmitting">
                    <span class="spinner-border spinner-border-sm me-2"></span>
                    Creating Account...
                </span>
                <span data-show="!isSubmitting">Create Account</span>
            </button>

            <div class="text-end">
                <small class="text-muted">
                    Form Progress: <span data-bind="validationProgress"></span>%
                </small>
                <div class="progress mt-1" style="width: 100px; height: 4px;">
                    <div class="progress-bar bg-success"
                         data-bind-style="{ width: validationProgress + '%' }"></div>
                </div>
            </div>
        </div>
    </form>
    
    <div class="mt-3" data-show="submissionSuccess">
        <div class="alert alert-success">
            <h5>✅ Account Created Successfully!</h5>
            <p>Welcome to our platform, <strong><span data-bind="form.username"></span></strong>! A confirmation email has been sent to <strong><span data-bind="form.email"></span></strong>.</p>
            <button type="button" data-action="resetForm" class="btn btn-success">
                Create Another Account
            </button>
        </div>
    </div>
    
    <div class="mt-3" data-show="showTermsModal">
        <div class="modal" style="display: block; background: rgba(0,0,0,0.5);">
            <div class="modal-dialog">
                <div class="modal-content">
                    <div class="modal-header">
                        <h5 class="modal-title">Terms and Conditions</h5>
                        <button type="button" data-action="hideTerms" class="btn-close"></button>
                    </div>
                    <div class="modal-body">
                        <p>By creating an account, you agree to our terms of service and privacy policy.</p>
                        <p>This is a demo form for educational purposes.</p>
                    </div>
                    <div class="modal-footer">
                        <button type="button" data-action="acceptTerms" class="btn btn-primary">
                            Accept Terms
                        </button>
                        <button type="button" data-action="hideTerms" class="btn btn-secondary">
                            Cancel
                        </button>
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>
wildflower.component('form-validation', {
    state: {
        form: {
            username: '',
            email: '',
            phone: '',
            age: '',
            password: '',
            confirmPassword: '',
            accountType: '',
            termsAccepted: false
        },
        errors: {
            username: '',
            email: '',
            phone: '',
            age: '',
            password: '',
            confirmPassword: '',
            accountType: '',
            terms: ''
        },
        touched: {
            username: false,
            email: false,
            phone: false,
            age: false,
            password: false,
            confirmPassword: false,
            accountType: false,
            terms: false
        },
        isSubmitting: false,
        submissionSuccess: false,
        showPassword: false,
        showTermsModal: false
    },
    
    computed: {
        isFormValid() {
            const form = this.form
            const errors = this.errors
            
            // Check all required fields are filled
            const requiredFields = ['username', 'email', 'phone', 'age', 'password', 'confirmPassword', 'accountType']
            const hasAllFields = requiredFields.every(field => form[field] && form[field].toString().trim())
            
            // Check no validation errors
            const hasNoErrors = Object.values(errors).every(error => !error)
            
            return hasAllFields && hasNoErrors && form.termsAccepted
        },
        
        validationProgress() {
            const form = this.form
            const errors = this.errors
            const totalFields = 8 // including termsAccepted
            
            let validFields = 0
            if (form.username && !errors.username) validFields++
            if (form.email && !errors.email) validFields++
            if (form.phone && !errors.phone) validFields++
            if (form.age && !errors.age) validFields++
            if (form.password && !errors.password) validFields++
            if (form.confirmPassword && !errors.confirmPassword) validFields++
            if (form.accountType && !errors.accountType) validFields++
            if (form.termsAccepted && !errors.terms) validFields++
            
            return Math.round((validFields / totalFields) * 100)
        },
        
        passwordStrengthScore() {
            const password = this.form.password
            if (!password) return 0
            
            let score = 0
            if (password.length >= 8) score++
            if (password.length >= 12) score++
            if (/[a-z]/.test(password)) score++
            if (/[A-Z]/.test(password)) score++
            if (/[0-9]/.test(password)) score++
            if (/[^A-Za-z0-9]/.test(password)) score++
            
            return score
        },
        
        passwordStrength() {
            const score = this.passwordStrengthScore
            if (score <= 1) return 'Very Weak'
            if (score <= 2) return 'Weak'
            if (score <= 3) return 'Fair'
            if (score <= 4) return 'Good'
            if (score <= 5) return 'Strong'
            return 'Very Strong'
        },
        
        passwordStrengthClass() {
            const strength = this.passwordStrength
            const baseClass = 'badge '
            switch (strength) {
                case 'Very Weak': return baseClass + 'bg-danger'
                case 'Weak': return baseClass + 'bg-warning'
                case 'Fair': return baseClass + 'bg-info'
                case 'Good': return baseClass + 'bg-primary'
                case 'Strong': return baseClass + 'bg-success'
                case 'Very Strong': return baseClass + 'bg-success'
                default: return baseClass + 'bg-secondary'
            }
        },
        
        passwordStrengthColor() {
            const strength = this.passwordStrength
            switch (strength) {
                case 'Very Weak': return 'bg-danger'
                case 'Weak': return 'bg-warning'
                case 'Fair': return 'bg-info'
                case 'Good': return 'bg-primary'
                case 'Strong': case 'Very Strong': return 'bg-success'
                default: return 'bg-secondary'
            }
        },

        passwordStrengthPercent() {
            return Math.min(Math.round((this.passwordStrengthScore / 6) * 100), 100)
        },
        
        submitButtonClass() {
            const isValid = this.isFormValid
            const isSubmitting = this.isSubmitting
            
            if (isSubmitting) return 'btn btn-primary'
            return isValid ? 'btn btn-primary' : 'btn btn-outline-primary'
        },
        
        // Field-specific validation classes
        getUsernameClass() {
            const { username } = this.form
            const error = this.errors.username
            if (this.touched.username && error) return 'form-control is-invalid'
            if (this.touched.username && username && !error) return 'form-control is-valid'
            return 'form-control'
        },

        getEmailClass() {
            const { email } = this.form
            const error = this.errors.email
            if (this.touched.email && error) return 'form-control is-invalid'
            if (this.touched.email && email && !error) return 'form-control is-valid'
            return 'form-control'
        },

        getPhoneClass() {
            const { phone } = this.form
            const error = this.errors.phone
            if (this.touched.phone && error) return 'form-control is-invalid'
            if (this.touched.phone && phone && !error) return 'form-control is-valid'
            return 'form-control'
        },

        getAgeClass() {
            const { age } = this.form
            const error = this.errors.age
            if (this.touched.age && error) return 'form-control is-invalid'
            if (this.touched.age && age && !error) return 'form-control is-valid'
            return 'form-control'
        },

        getPasswordClass() {
            const { password } = this.form
            const error = this.errors.password
            if (this.touched.password && error) return 'form-control is-invalid'
            if (this.touched.password && password && !error) return 'form-control is-valid'
            return 'form-control'
        },

        getConfirmPasswordClass() {
            const { confirmPassword } = this.form
            const error = this.errors.confirmPassword
            if (this.touched.confirmPassword && error) return 'form-control is-invalid'
            if (this.touched.confirmPassword && confirmPassword && !error) return 'form-control is-valid'
            return 'form-control'
        },

        getAccountTypeClass() {
            const { accountType } = this.form
            const error = this.errors.accountType
            if (this.touched.accountType && error) return 'form-select is-invalid'
            if (this.touched.accountType && accountType && !error) return 'form-select is-valid'
            return 'form-select'
        },

        getTermsClass() {
            const error = this.errors.terms
            if (this.touched.terms && error) return 'form-check-input is-invalid'
            if (this.touched.terms && this.form.termsAccepted) return 'form-check-input is-valid'
            return 'form-check-input'
        }
    },
    
    // Validation methods
    validateUsername() {
        this.touched.username = true
        const username = this.form.username.trim()
        if (!username) {
            this.errors.username = 'Username is required'
        } else if (username.length < 3) {
            this.errors.username = 'Username must be at least 3 characters'
        } else if (username.length > 20) {
            this.errors.username = 'Username must be no more than 20 characters'
        } else if (!/^[a-zA-Z0-9_]+$/.test(username)) {
            this.errors.username = 'Username can only contain letters, numbers, and underscores'
        } else {
            this.errors.username = ''
        }
    },
    
    clearUsernameError() {
        if (this.errors.username) {
            this.errors.username = ''
        }
    },
    
    validateEmail() {
        this.touched.email = true
        const email = this.form.email.trim()
        const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
        
        if (!email) {
            this.errors.email = 'Email is required'
        } else if (!emailRegex.test(email)) {
            this.errors.email = 'Please enter a valid email address'
        } else if (email.length > 254) {
            this.errors.email = 'Email address is too long'
        } else {
            this.errors.email = ''
        }
    },
    
    validatePhone() {
        this.touched.phone = true
        const phone = this.form.phone.replace(/\D/g, '')
        if (!phone) {
            this.errors.phone = 'Phone number is required'
        } else if (phone.length !== 10) {
            this.errors.phone = 'Please enter a valid 10-digit phone number'
        } else {
            this.errors.phone = ''
        }
    },
    
    formatPhone() {
        const phone = this.form.phone.replace(/\D/g, '')
        if (phone.length >= 6) {
            this.form.phone = `(${phone.slice(0, 3)}) ${phone.slice(3, 6)}-${phone.slice(6, 10)}`
        } else if (phone.length >= 3) {
            this.form.phone = `(${phone.slice(0, 3)}) ${phone.slice(3)}`
        }
    },
    
    validateAge() {
        this.touched.age = true
        const age = parseInt(this.form.age)
        if (!this.form.age) {
            this.errors.age = 'Age is required'
        } else if (isNaN(age) || age < 13) {
            this.errors.age = 'You must be at least 13 years old'
        } else if (age > 120) {
            this.errors.age = 'Please enter a valid age'
        } else {
            this.errors.age = ''
        }
    },
    
    validatePassword() {
        this.touched.password = true
        const password = this.form.password
        if (!password) {
            this.errors.password = 'Password is required'
        } else if (password.length < 8) {
            this.errors.password = 'Password must be at least 8 characters'
        } else if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])/.test(password)) {
            this.errors.password = 'Password must contain lowercase, uppercase, and number'
        } else {
            this.errors.password = ''
        }
        
        // Re-validate confirm password if it exists
        if (this.form.confirmPassword) {
            this.validateConfirmPassword()
        }
    },
    
    validateConfirmPassword() {
        this.touched.confirmPassword = true
        const password = this.form.password
        const confirmPassword = this.form.confirmPassword
        
        if (!confirmPassword) {
            this.errors.confirmPassword = 'Please confirm your password'
        } else if (password !== confirmPassword) {
            this.errors.confirmPassword = 'Passwords do not match'
        } else {
            this.errors.confirmPassword = ''
        }
    },
    
    validateAccountType() {
        this.touched.accountType = true
        if (!this.form.accountType) {
            this.errors.accountType = 'Please select an account type'
        } else {
            this.errors.accountType = ''
        }
    },
    
    validateTerms() {
        this.touched.terms = true
        if (!this.form.termsAccepted) {
            this.errors.terms = 'You must accept the terms and conditions'
        } else {
            this.errors.terms = ''
        }
    },
    
    // UI interaction methods
    togglePasswordVisibility() {
        this.showPassword = !this.showPassword
        const passwordInput = this.element.querySelector('#password-input')
        if (passwordInput) {
            passwordInput.type = this.showPassword ? 'text' : 'password'
        }
    },
    
    showTerms() {
        this.showTermsModal = true
    },
    
    hideTerms() {
        this.showTermsModal = false
    },
    
    acceptTerms() {
        this.form.termsAccepted = true
        this.validateTerms()
        this.hideTerms()
    },
    
    // Form submission
    handleSubmit(event) {
        event.preventDefault()
        
        // Validate all fields
        this.validateUsername()
        this.validateEmail()
        this.validatePhone()
        this.validateAge()
        this.validatePassword()
        this.validateConfirmPassword()
        this.validateAccountType()
        this.validateTerms()
        
        if (this.isFormValid) {
            this.isSubmitting = true
            
            // Simulate API call
            setTimeout(() => {
                this.isSubmitting = false
                this.submissionSuccess = true
                
                // Hide success message after 8 seconds
                setTimeout(() => {
                    this.submissionSuccess = false
                }, 8000)
            }, 2000)
        }
    },
    
    resetForm() {
        this.form = {
            username: '',
            email: '',
            phone: '',
            age: '',
            password: '',
            confirmPassword: '',
            accountType: '',
            termsAccepted: false
        }
        this.errors = {
            username: '',
            email: '',
            phone: '',
            age: '',
            password: '',
            confirmPassword: '',
            accountType: '',
            terms: ''
        }
        this.touched = {
            username: false, email: false, phone: false, age: false,
            password: false, confirmPassword: false, accountType: false, terms: false
        }
        this.submissionSuccess = false
        this.showPassword = false
        this.showTermsModal = false
    }
})
Live Preview

Dynamic Forms

Create forms that dynamically change structure based on user input with conditional fields and sections:

<div data-component="dynamic-form">
    <form data-action="handleSubmit">
        <div class="mb-3">
            <label class="form-label">Account Type:</label>
            <select data-model="accountType" class="form-select">
                <option value="">Select type...</option>
                <option value="personal">Personal</option>
                <option value="business">Business</option>
            </select>
        </div>

        <div data-show="accountType === 'personal'" class="mb-3 p-3 border rounded">
            <h6>Personal Details</h6>
            <div class="mb-2">
                <label class="form-label">Full Name:</label>
                <input type="text" data-model="personal.name" class="form-control" placeholder="Your name">
            </div>
            <div class="mb-2">
                <label class="form-label">Email:</label>
                <input type="email" data-model="personal.email" class="form-control" placeholder="you@example.com">
            </div>
        </div>

        <div data-show="accountType === 'business'" class="mb-3 p-3 border rounded">
            <h6>Business Details</h6>
            <div class="mb-2">
                <label class="form-label">Company Name:</label>
                <input type="text" data-model="business.company" class="form-control" placeholder="Acme Inc.">
            </div>
            <div class="mb-2">
                <label class="form-label">Your Role:</label>
                <input type="text" data-model="business.role" class="form-control" placeholder="CTO">
            </div>
        </div>

        <div data-show="accountType" class="mt-3">
            <button type="submit" class="btn btn-primary me-2">Submit</button>
            <button type="button" data-action="resetForm" class="btn btn-secondary">Reset</button>
        </div>
    </form>

    <div class="mt-3" data-show="result">
        <div class="alert alert-success">
            Submitted: <code data-bind="result"></code>
        </div>
    </div>
</div>
wildflower.component('dynamic-form', {
    state: {
        accountType: 'personal',
        personal: { name: '', email: '' },
        business: { company: '', role: '' },
        result: ''
    },

    handleSubmit(event) {
        event.preventDefault()
        const data = this.accountType === 'personal'
            ? this.personal
            : this.business
        this.result = JSON.stringify(data)
    },

    resetForm() {
        this.accountType = ''
        this.personal = { name: '', email: '' }
        this.business = { company: '', role: '' }
        this.result = ''
    }
})
Live Preview