Input Elements

Text Input

For text inputs and similar (search, password, etc.), you can just use the input class.

/routes/forms/strings/+page.svelte

<label>
	<span>My Text</span>
	<input type="text" class="input" placeholder="Basic text input" />
</label>

Number Input

NumberInput wraps a native number input. Add unit="mm", unit="%" or another unit to display a muted suffix after the current number. Units also follow numeric placeholders, hide for text placeholders like “Auto”, and align right when there is no value or placeholder. The bound value stays numeric.

mm
%
s
s
mm
mm

/routes/forms/numbers/+page.svelte

<script lang="ts">
	import { NumberInput } from '@leon8ix/jhana-ui';
</script>

<div class="fields">
	<NumberInput value={1} min={0} label="Copies" />
	<NumberInput value={210} min={0} label="Width" unit="mm" />
	<NumberInput value={75} min={0} max={100} label="Opacity" unit="%" />
	<NumberInput value={null} step={0.1} label="Duration" unit="s" placeholder="Auto" />
	<NumberInput value={null} step={0.1} label="Delay" unit="s" placeholder="1.5" />
	<NumberInput value={null} step={0.1} label="Timeout" unit="s" />
	<NumberInput value={210} label="Disabled" unit="mm" disabled />
	<NumberInput value={210} label="Readonly" unit="mm" readonly />
</div>

Label Actions

NumberInput and Field accept a labelEnd snippet for custom content beside the label and help. This example combines Infinipose's Gutter X field with its toolbar reset button. Change the value to reveal the reset action. ButtonRevert keeps its space when hidden, so the field stays the same width. The snippet sits outside the clickable label and can contain any component. The app controls its behavior, visibility and disabled state. Use type="button" for actions inside forms.

mm

NumberInputLabelDemo.svelte

<script lang="ts">
	import { ButtonRevert, NumberInput } from '@leon8ix/jhana-ui';
	import { tooltip } from '@leon8ix/jhana-ui/actions';

	const defaultGutter = 2;
	let gutter = $state<number | null>(defaultGutter);
</script>

<NumberInput label="Gutter X" unit="mm" bind:value={gutter} help="Horizontal space between adjacent pages.">
	{#snippet labelEnd()}
		<ButtonRevert
			type="button"
			name="Reset Gutter X"
			hidden={gutter === defaultGutter}
			onclick={() => (gutter = defaultGutter)}
			{@attach tooltip('Restore the default horizontal gutter.', { delay: 'long' })}
		/>
	{/snippet}
</NumberInput>

Combo Box

Editable text with string or structured suggestions. Object options use value for selection and label for search; an option snippet can render extra data. Use null to separate groups.

/routes/forms/combo-box/+page.svelte

<script lang="ts">
	import { ActionButton, ComboBox, iconActionRemove } from '@leon8ix/jhana-ui';

	let fruit = $state('Apple');
	const fruits = ['Apple', 'Apricot', 'Banana', 'Blueberry', 'Cherry'];
</script>

<div class="input-group">
	<ComboBox aria-label="Fruit" bind:value={fruit} options={fruits}>
		<ActionButton label="Clear" icon={iconActionRemove} class="theme-error" onclick={() => (fruit = '')} />
	</ComboBox>
</div>

<style>
	.input-group > :global(.combo-box) {
		width: 20em;
		max-width: 100%;
	}
</style>

Async Combo Box

The saved-cover example loads pages and searches asynchronously. onSet opens the chosen file without writing its ID into the input. The popup uses native popover and adjusts to the available space while scrolling.

/routes/forms/combo-box-async/+page.svelte

<script lang="ts">
	import { ComboBox } from '@leon8ix/jhana-ui';
	import { delay } from '@leon8ix/utils/dom';

	const files = Array.from({ length: 80 }, (_, i) => ({
		value: String(i + 1),
		label: 'Cover ' + String(i + 1).padStart(3, '0') + '.json',
		date: '2026-09-' + String(1 + (i % 27)).padStart(2, '0'),
	}));
	let fileName = $state<string | null>(null);
	let opened = $state<string | null>(null);
	let page = $state.raw<typeof files>([]);

	async function updateFiles() {
		await delay(400);
		page = files.slice(0, 30);
	}
	async function loadMoreFiles() {
		await delay(400);
		page = files.slice(0, page.length + 20);
	}
	async function searchFiles(query: string) {
		await delay(400);
		return files.filter(file => file.label.toLowerCase().includes(query.toLowerCase()));
	}
</script>

<div class="input-group">
	<ComboBox
		aria-label="Saved cover"
		bind:value={fileName}
		options={page}
		update={updateFiles}
		loadMore={page.length < files.length ? loadMoreFiles : undefined}
		search={searchFiles}
		placeholder={opened ?? 'Open a cover...'}
		onSet={(_, file) => {
			opened = file.label;
			fileName = null;
		}}
	>
		{#snippet option(file)}
			<span class="file numbers">
				<span>{file.label}</span>
				<time datetime={file.date}>{file.date}</time>
			</span>
		{/snippet}
	</ComboBox>
</div>

<style>
	.input-group > :global(.combo-box) {
		width: 20em;
		max-width: 100%;
	}
	.file {
		display: flex;
		flex-wrap: wrap;
		justify-content: space-between;
		gap: 0 0.8em;
	}
	time {
		font-size: 0.8em;
		opacity: 0.7;
	}
</style>

Toggle

Toggle wraps a native checkbox. Add boxed for an input-style surface, and pass an object with off and on labels for state-dependent text that reserves space for both. See Layout and Fields for boxed examples with a top label.

Preview

/routes/forms/toggles/+page.svelte

<script lang="ts">
	import { Help, Toggle } from '@leon8ix/jhana-ui';

	let options = $state([
		{ label: 'Guides', checked: false, help: 'Shows cutting lines in magenta and creasing lines in cyan.' },
		{
			label: 'Page numbers',
			checked: true,
			help: 'Shows the source page number at each output position. Blank pages use a dash.',
		},
		{
			label: 'Registration marks',
			checked: true,
			help: 'Shows the machine’s registration marks when they are enabled in the layout.',
		},
		{
			label: 'Image',
			checked: true,
			help: 'Shows the source file’s page content. Hide it to inspect the layout more easily.',
		},
		{
			label: 'Safety areas',
			checked: false,
			help: 'Shows the margins and registration-mark clearances as translucent magenta areas.',
		},
	]);
</script>

<fieldset class="field-group">
	<legend>Preview</legend>
	<div class="stack" style:gap="var(--space-2)">
		{#each options as option}
			<div class="field-head">
				<Toggle label={option.label} bind:checked={option.checked} />
				<Help label={`Help: ${option.label}`} text={option.help} />
			</div>
		{/each}
	</div>
</fieldset>

Infinipose's Preview options form one compact vertical group, with help beside each switch. Its More options group uses one shared box for two related switches. Inside a shared box, leave the individual toggles unboxed. Use 0.5em between closely related switches and 1em between independent groups.

More options

ToggleGroupDemo.svelte

<script lang="ts">
	import { Toggle } from '@leon8ix/jhana-ui';

	let gutter = $state(true);
	let printMargin = $state(false);
</script>

<fieldset class="field-group">
	<legend>More options</legend>
	<div class="input-group boxed vrt" style:gap="var(--space-2)">
		<Toggle label="Gutter" bind:checked={gutter} />
		<Toggle label="Synthetic print margin" bind:checked={printMargin} />
	</div>
</fieldset>

Radios

Radio wraps a native radio input. Place related radios in one input-group boxed container for a shared box matching a text or number input. This is required when the group is beside an input field. Infinipose's Group copies setting binds a boolean to two directly named choices, each with its own explanation. The number sequences use the numbers class and 70% opacity. See Layout and Fields for an aligned example.

Group copies

/routes/forms/toggles/+page.svelte

<script lang="ts">
	import { Radio } from '@leon8ix/jhana-ui';
	import { tooltip } from '@leon8ix/jhana-ui/actions';

	const uid = $props.id();
	let grouped = $state(false);
</script>

<fieldset class="field-group">
	<legend>Group copies</legend>
	<div class="input-group boxed">
		<Radio
			name={uid}
			value={false}
			label="Collate"
			bind:group={grouped}
			{@attach tooltip('Repeats the complete page sequence for each copy.', { delay: 'long' })}
		>
			<span class="numbers" style:opacity={0.7}>1,2,1,2</span>
		</Radio>
		<Radio
			name={uid}
			value={true}
			label="Group"
			bind:group={grouped}
			{@attach tooltip('Places all copies of one page group together before the next group.', { delay: 'long' })}
		>
			<span class="numbers" style:opacity={0.7}>1,1,2,2</span>
		</Radio>
	</div>
</fieldset>

Radio Buttons

This example preserves Portal's recipient choices: self-service printers 1, 4, 5 and 6, or an employee. For button-style choices, pair native radio inputs with adjacent labels styled as btn dark. Add primary to the selected label. The inputs keep native keyboard navigation and form values; each label is the visible button. Attach the tooltip to the input: it follows the label and opens instantly on hover by default. A touch tap selects the option and shows its explanation. Use the delay option when an individual choice needs a slower tooltip. Set openOnSelect to false at any delay to make clicks and taps only select the option; hover, keyboard focus, or native touch long press opens its optional explanation.

Recipient

RadioButtonsDemo.svelte

<script lang="ts">
	import { tooltip } from '@leon8ix/jhana-ui/actions';

	const uid = $props.id();
	const options = [
		...[1, 4, 5, 6].map(number => ({
			value: `sb${number}`,
			label: `No. ${number}`,
			help: `Sends the file to the Portal folder on self-service PC ${number}. Choose the printer number assigned to you.`,
		})),
		{
			value: 'tmp',
			label: 'Employee',
			help: 'For excellent print quality or a specific paper, let an employee print the file for you.',
		},
	];
	let recipient = $state('tmp');
</script>

<fieldset class="field-group">
	<legend>Recipient</legend>
	<div class="btn-group">
		{#each options as option}
			{@const id = `${uid}-${option.value}`}
			<input
				type="radio"
				name={uid}
				{id}
				value={option.value}
				bind:group={recipient}
				{@attach tooltip(option.help)}
			/>
			<label class="btn dark" class:primary={recipient === option.value} for={id}>{option.label}</label>
		{/each}
	</div>
</fieldset>

Radio Cards

Use the same native radio and adjacent label pattern with the card class for choices containing icons or diagrams. Add primary to the selected card. Its text uses 75% accent mixed with 25% white for better visibility. Icon inherits that tint by default. Custom diagrams use currentColor. Selection preserves the card's dimensions. Keyboard focus uses the card's hover emphasis without an outline. This example uses four of Infinipose's page-orientation diagrams in a two-column grid.

Page orientation

RadioCardsDemo.svelte

<script lang="ts">
	const uid = $props.id();
	const options = [
		{ value: 'standard', label: 'Standard', arrows: '↑↑↑↑' },
		{ value: 'upside-down', label: 'Upside down', arrows: '↓↓↓↓' },
		{ value: 'head-to-head', label: 'Head to head', arrows: '↓↓↑↑' },
		{ value: 'foot-to-foot', label: 'Foot to foot', arrows: '↑↑↓↓' },
	];
	let orientation = $state('standard');
</script>

<fieldset class="field-group" style="width: min(24em, 100%)">
	<legend>Page orientation</legend>
	<div class="style-options">
		{#each options as option}
			{@const id = `${uid}-${option.value}`}
			<input type="radio" name={uid} {id} value={option.value} bind:group={orientation} />
			<label class="card style-option" class:primary={orientation === option.value} for={id}>
				<span class="style-diagram" aria-hidden="true">
					{#each [...option.arrows] as arrow}<span>{arrow}</span>{/each}
				</span>
				<span>{option.label}</span>
			</label>
		{/each}
	</div>
</fieldset>

<style>
	.style-options {
		display: grid;
		grid-template-columns: repeat(2, minmax(0, 1fr));
		gap: var(--space-2);
	}
	.style-option {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: var(--space-2);
		text-align: center;
		font-size: 0.75em;
		line-height: 1.3;
	}
	.style-diagram {
		display: grid;
		grid-template-columns: repeat(2, 1fr);
		gap: 2px;
		font-size: 1.25em;
	}
	.style-diagram > span {
		width: 1.05em;
		padding: 0.05em 0;
		background: color-mix(in display-p3, currentColor 8%, transparent);
		border: 1px solid color-mix(in display-p3, currentColor 30%, transparent);
		border-radius: 2px;
	}
</style>