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

@samuel-charpentier/sform

v0.0.12

Published

A Svelte 5 form library for SvelteKit remote forms

Downloads

1,212

Readme

Sform

A type-safe form library for Svelte 5 with SvelteKit remote functions.

Table of Contents

Features

  • Type-safe - Discriminated union types for each input type
  • Preflight validation - All errors shown on submit, not one at a time
  • Validate modes - blur, change, or submit
  • Password toggle - Eye icon to show/hide password
  • Masked inputs - Phone, credit card, SSN formatting
  • Range slider - With optional value display
  • Toggle switch - Modern on/off control
  • Toggle options - Segmented control for mutually exclusive options
  • Stateful button - Shows pending state during submission

Requirements

  • Svelte 5
  • SvelteKit with remoteFunctions: true in config
  • Valibot for schema validation

Installation

npm install @samuel-charpentier/sform

Enable remote functions in svelte.config.js:

export default {
	kit: {
		experimental: {
			remoteFunctions: true
		}
	}
};

Quick Start

1. Create a Remote Form

Create a .remote.ts file with your form schema and handler:

// src/routes/auth.remote.ts
import * as v from 'valibot';
import { form } from '@sveltejs/kit/remote';

const loginSchema = v.object({
	username: v.pipe(v.string(), v.minLength(3, 'Username must be at least 3 characters')),
	_password: v.pipe(v.string(), v.minLength(8, 'Password must be at least 8 characters'))
});

export const login = form(loginSchema, async ({ username, _password }) => {
	// Your authentication logic here
	return { success: true, message: 'Welcome!' };
});

2. Create Your Form Component

<script lang="ts">
	import { Sform, Sfield, Sbutton } from '@samuel-charpentier/sform';
	import { login } from './auth.remote.ts';
</script>

<Sform form={login} validateOn="blur">
	{#snippet children(fields)}
		<Sfield field={fields.username} type="text" label="Username" />
		<Sfield field={fields._password} type="password" label="Password" />

		<Sbutton form={login} label="Login" />
	{/snippet}
</Sform>

Components

<Sform>

Wrapper component that provides form context to all child fields.

<Sform form={remoteForm} validateOn="blur" class="my-form">
	{#snippet children(fields)}
		<!-- Sfield components here -->
	{/snippet}
</Sform>

| Prop | Type | Default | Description | | --------------- | -------------------------------- | ----------- | ------------------------------------------------- | | form | RemoteForm | required | Remote form object from form() API | | validateOn | 'blur' \| 'change' \| 'submit' | 'blur' | When to validate and show errors | | class | string | undefined | CSS class for form element | | preflightOnly | boolean | false | If true, client side validation is preflight only |

Validate Modes:

  • blur - Validate and show errors after leaving field (default)
  • change - Validate and show errors as soon as value changes
  • submit - Validate and show all errors only after submit attempt

<Sfield>

Smart field component with type-safe props based on input type.

Common Props (all types)

| Prop | Type | Default | Description | | ------------- | ------------------------- | ----------- | ------------------------------------- | | field | RemoteFormField | required | Field from fields snippet parameter | | type | InputType | required | Input type | | label | string | undefined | Field label | | placeholder | string | undefined | Placeholder text (text/password/etc) | | disabled | boolean | false | Disable the field | | readonly | boolean | false | Make field readonly | | validateOn | ValidateOn | inherited | Override form validateOn | | class | SfieldClasses \| string | undefined | CSS classes | | hint | string \| Snippet | undefined | Help text shown below the field |

Text Inputs

<Sfield field={fields.email} type="email" label="Email" placeholder="[email protected]" />
<Sfield field={fields.search} type="search" label="Search" />
<Sfield field={fields.phone} type="tel" label="Phone" />
<Sfield field={fields.website} type="url" label="Website" prefix="https://" />

Supported text types: text, email, tel, url, search, date, datetime-local, time, month, week, color, file

| Prop | Type | Default | Description | | -------- | ------------------- | ----------- | -------------------- | | prefix | string \| Snippet | undefined | Content before input | | suffix | string \| Snippet | undefined | Content after input |

Password Input

<Sfield field={fields._password} type="password" label="Password" />
<Sfield field={fields._password} type="password" label="Password" showToggle={false} />
<Sfield field={fields._password} type="password" label="Password">
	{#snippet showToggleIcon(passwordShown)}
		{#if passwordShown}
			🙈
		{:else}
			👁️
		{/if}
	{/snippet}
</Sfield>

| Prop | Type | Default | Description | | ---------------- | ----------------------------------- | ----------- | ---------------------------------- | | showToggle | boolean | true | Show eye icon to toggle visibility | | showToggleIcon | Snippet<[passwordShown: boolean]> | undefined | Custom toggle icon snippet | | prefix | string \| Snippet | undefined | Content before input | | suffix | string \| Snippet | undefined | Content after input | | autocomplete | string | undefined | HTML autocomplete attribute |

Number Input

<Sfield field={fields.age} type="number" label="Age" min={0} max={150} step={1} />
<Sfield field={fields.price} type="number" label="Price" prefix="$" suffix="USD" align="end" />
<Sfield field={fields.quantity} type="number" label="Qty" showControls={false} maxDecimals={0} />

| Prop | Type | Default | Description | | -------------- | ------------------- | ----------- | -------------------------------------- | | min | number \| string | undefined | Minimum value | | max | number \| string | undefined | Maximum value | | step | number \| string | undefined | Step increment | | prefix | string \| Snippet | undefined | Content before input (e.g., "$") | | suffix | string \| Snippet | undefined | Content after input (e.g., "USD") | | showControls | boolean | true | Show spinner controls | | align | 'start' \| 'end' | 'start' | Text alignment | | maxDecimals | number | undefined | Max decimal places (0 = integers only) | | autocomplete | string | undefined | HTML autocomplete attribute |

Textarea

<Sfield field={fields.bio} type="textarea" label="Bio" placeholder="Tell us about yourself" />
<Sfield field={fields.notes} type="textarea" label="Notes" prefix="📝" suffix="(max 500 chars)" />

| Prop | Type | Default | Description | | -------- | ------------------- | ----------- | -------------------- | | prefix | string \| Snippet | undefined | Content before input | | suffix | string \| Snippet | undefined | Content after input |

Select

<Sfield
	field={fields.country}
	type="select"
	label="Country"
	options={[
		{ value: 'us', label: 'United States' },
		{ value: 'uk', label: 'United Kingdom' },
		{ value: 'ca', label: 'Canada' }
	]}
/>

| Prop | Type | Default | Description | | -------------- | ---------------------------- | ----------- | --------------------------- | | options | SelectOption[] \| string[] | required | Select options | | autocomplete | string | undefined | HTML autocomplete attribute |

Checkbox

<Sfield field={fields.subscribe} type="checkbox" label="Subscribe to newsletter" />

Radio

<Sfield
	field={fields.plan}
	type="radio"
	label="Plan"
	options={[
		{ value: 'free', label: 'Free' },
		{ value: 'pro', label: 'Pro' },
		{ value: 'enterprise', label: 'Enterprise' }
	]}
/>

| Prop | Type | Default | Description | | --------- | ---------------------------- | ----------- | ------------------------ | | options | SelectOption[] \| string[] | undefined | Radio options for groups |

Range

<Sfield field={fields.volume} type="range" label="Volume" min={0} max={100} step={5} showValue />
<Sfield
	field={fields.brightness}
	type="range"
	label="Brightness"
	min={0}
	max={100}
	formatValue={(v) => `${v}%`}
	showValue
/>

| Prop | Type | Default | Description | | -------------- | --------------------------- | ----------- | --------------------------- | | min | number \| string | 0 | Minimum value | | max | number \| string | 100 | Maximum value | | step | number \| string | 1 | Step increment | | showValue | boolean | false | Show current value | | formatValue | (value: number) => string | undefined | Format displayed value | | autocomplete | string | undefined | HTML autocomplete attribute |

Toggle

<Sfield field={fields.notifications} type="toggle" label="Enable Notifications" />
<Sfield field={fields.darkMode} type="toggle" label="Theme" onLabel="Dark" offLabel="Light" />

| Prop | Type | Default | Description | | ---------------- | -------- | ----------- | -------------------- | | onLabel | string | undefined | Label when on | | offLabel | string | undefined | Label when off | | checkedValue | string | 'true' | Value when checked | | uncheckedValue | string | 'false' | Value when unchecked |

Toggle Options

<Sfield
	field={fields.theme}
	type="toggle-options"
	label="Theme"
	options={[
		{ value: 'light', label: 'Light' },
		{ value: 'dark', label: 'Dark' },
		{ value: 'auto', label: 'Auto' }
	]}
/>
<!-- Multiple selection -->
<Sfield
	field={fields.features}
	type="toggle-options"
	label="Features"
	multiple={true}
	options={[
		{ value: 'push', label: 'Push Notifications' },
		{ value: 'email', label: 'Email' },
		{ value: 'sms', label: 'SMS' }
	]}
/>

| Prop | Type | Default | Description | | ---------- | ---------------------------- | -------- | ------------------------- | | options | ToggleOption[] \| string[] | required | Toggle options | | multiple | boolean | false | Allow multiple selections |

Masked Input

<Sfield field={fields.phone} type="masked" label="Phone" mask="(###) ###-####" />
<Sfield field={fields.creditCard} type="masked" label="Credit Card" mask="#### #### #### ####" />
<Sfield field={fields.ssn} type="masked" label="SSN" mask="###-##-####" />
<!-- Custom tokens -->
<Sfield
	field={fields.code}
	type="masked"
	label="Code"
	mask="AAAA-99-LL"
	tokens={{ A: /[A-Z]/, L: /[a-z]/ }}
/>

| Prop | Type | Default | Description | | --------------------- | ------------------------ | ----------- | -------------------------------- | | mask | string | required | Mask pattern | | tokens | Record<string, RegExp> | undefined | Custom token definitions | | maskPlaceholder | string | '_' | Placeholder character | | showMaskPlaceholder | boolean | false | Show full mask with placeholders | | unmaskValue | boolean | true | Store unmasked value | | prefix | string \| Snippet | undefined | Content before input | | suffix | string \| Snippet | undefined | Content after input | | autocomplete | string | undefined | HTML autocomplete attribute |

Mask Tokens:

  • # or 9 - Numeric (0-9)
  • a - Alphabetic (a-z, A-Z)
  • A - Alphabetic uppercase
  • * - Alphanumeric

Hidden Input

<Sfield field={fields.token} type="hidden" value={authToken} />
<Sfield field={fields.userId} type="hidden" value="12345" />

| Prop | Type | Default | Description | | ------- | -------- | ------- | ------------------------------------------------ | | value | string | '' | The value for the hidden field (can be reactive) |

Hidden inputs are useful for including data in form submissions without displaying it to the user. The value prop is reactive, so you can update it programmatically:

<script lang="ts">
	let token = $state(initialToken);

	async function refreshToken() {
		token = await getNewToken();
	}
</script>

<Sfield field={fields.token} type="hidden" value={token} />

<Sbutton>

Stateful submit button that reacts to form state. Pass the form prop to enable typed result access.

<Sbutton form={myForm} label="Submit" class="my-button" />

<!-- With custom state rendering -->
<Sbutton form={myForm} class="submit-btn">
	{#snippet children(state)}
		{#if state.state === 'pending'}
			Submitting...
		{:else if state.state === 'success'}
			✓ {state.result.message}
		{:else if state.state === 'hasIssues'}
			Fix Errors
		{:else}
			Submit Form
		{/if}
	{/snippet}
</Sbutton>

The state parameter is a discriminated union of type ButtonState<T> where T is inferred from the form's result type:

type ButtonState<T = unknown> =
	| { state: 'default'; pending: false; success: false; hasIssues: false; result: undefined }
	| { state: 'pending'; pending: true; success: false; hasIssues: false; result: undefined }
	| { state: 'success'; pending: false; success: true; hasIssues: false; result: T }
	| { state: 'hasIssues'; pending: false; success: false; hasIssues: true; result: undefined };

Typed Result Access

The result type is automatically inferred from the form prop. When your remote function returns a typed result, you can access it directly:

<script lang="ts">
	import { login } from './auth.remote'; // Returns { success: boolean; message: string }
</script>

<Sbutton form={login} class="submit-btn">
	{#snippet children(state)}
		{#if state.state === 'success'}
			{state.result.message} <!-- TypeScript knows this is string -->
		{:else if state.state === 'pending'}
			Logging in...
		{:else}
			Login
		{/if}
	{/snippet}
</Sbutton>

| Prop | Type | Default | Description | | ------------ | --------------------------------- | ----------- | --------------------------------- | | form | RemoteForm | required | Remote form for type inference | | label | string | 'Submit' | Button text (when no children) | | buttonType | 'submit' \| 'reset' \| 'button' | 'submit' | Button type | | class | string | undefined | CSS class | | disabled | boolean | false | Disable button | | children | Snippet<[ButtonState<T>]> | undefined | Custom content with typed state | | onsubmit | () => void \| Promise<void> | undefined | Callback before validation/submit |

<SIssues>

Displays form-level issues and issues not shown by any Sfield component (e.g., hidden field issues or programmatic validation via invalid()).

<SIssues message="There are some issues with your form:" />

<!-- With custom message snippet -->
<SIssues>
	{#snippet message()}
		<strong>⚠️ Please fix the following issues:</strong>
	{/snippet}
</SIssues>

| Prop | Type | Default | Description | | ----------- | ------------------- | --------------------- | --------------------------------- | | message | string \| Snippet | undefined | General message shown when issues | | class | string | 'sform-issues' | CSS class for wrapper | | listClass | string | 'sform-issues-list' | CSS class for issues list |

The component filters issues to only show:

  • Form-level issues (from invalid("message"))
  • Field issues for hidden inputs (no Sfield displays them)
  • Issues for fields without a corresponding Sfield

<SResult>

Displays form result with typed access. Only renders when the form has a result. Pass the form prop to enable typed result access in the children snippet.

<SResult form={myLogin} class="sform-result sform-result-success">
	{#snippet children(result)}
		{result.message}
	{/snippet}
</SResult>

The result parameter is typed based on your remote function's return type:

<script lang="ts">
	import { login } from './auth.remote'; // Returns { success: boolean; message: string }
</script>

<SResult form={login} class="success-message">
	{#snippet children(result)}
		<!-- TypeScript knows result is { success: boolean; message: string } -->
		<h2>Welcome!</h2>
		<p>{result.message}</p>
	{/snippet}
</SResult>

| Prop | Type | Default | Description | | ---------- | -------------- | ----------- | ------------------------------ | | form | RemoteForm | required | Remote form for type inference | | children | Snippet<[T]> | required | Content with typed result | | class | string | undefined | CSS class for wrapper |

The component only renders when form.result !== undefined, so the result parameter in the children snippet is guaranteed to be defined.

Styling

CSS Classes

Sfield adds these classes automatically:

  • .sform-field - Wrapper element
  • .sform-label - Label element
  • .sform-input - Input element
  • .sform-messages - Error messages container
  • .sform-field-error - Added to wrapper when field has errors

Custom Classes

<!-- String class applies to wrapper -->
<Sfield field={fields.email} type="email" class="my-field" />

<!-- Object for granular control -->
<Sfield
	field={fields.email}
	type="email"
	class={{
		wrapper: 'field-wrapper',
		label: 'field-label',
		input: 'field-input',
		messages: 'field-errors'
	}}
/>

Validation

Sform uses preflight validation with Valibot schemas. Native browser validation (required, minlength, pattern) is disabled to allow showing all errors at once on submit.

Schema Example

import * as v from 'valibot';

const signupSchema = v.object({
	email: v.pipe(v.string(), v.email('Please enter a valid email')),
	_password: v.pipe(
		v.string(),
		v.minLength(8, 'Password must be at least 8 characters'),
		v.regex(/[A-Z]/, 'Password must contain an uppercase letter'),
		v.regex(/[0-9]/, 'Password must contain a number')
	),
	age: v.pipe(v.number(), v.minValue(18, 'Must be at least 18 years old'))
});

Type Safety

Sform uses TypeScript discriminated unions to provide type-safe props for each input type:

// ✅ TypeScript knows 'showToggle' is only valid for password type
<Sfield field={fields._password} type="password" showToggle={false} />

// ✅ TypeScript knows 'options' is required for select type
<Sfield field={fields.country} type="select" options={countries} />

// ✅ TypeScript knows 'min', 'max', 'step' are valid for number type
<Sfield field={fields.age} type="number" min={0} max={150} />

// ❌ TypeScript error: 'showToggle' doesn't exist on text type
<Sfield field={fields.username} type="text" showToggle />

Development

# Install dependencies
npm install

# Start dev server
npm run dev

# Run tests
npm test

# Build library
npm run package

License

MIT