Layout and Fields

These examples use translated settings from Infinipose: paper format, page groups, creep, target dimensions, thread sewing and processing profiles. The standalone controls demonstrate layout and interaction. Document planning, file processing and persistence remain the app's responsibility.

Labels, group titles, and headings

A field label names one input and focuses it when clicked. A legend names a group of related controls. Headings describe sections of the page. The h5 class only styles a heading; it does not label an input. Top labels and group titles share font weight 600. A label or single-level group title has a 0.25em gap before its controls. Toggle side labels and radio option names stay at 500.

When a fieldset contains fields with their own labels, its legend automatically gains a thin divider after the title and help icon, with 0.5em before the fields below. Paper format uses this distinction above Width and Height. A group starting with radio or toggle choices keeps its plain title, even if labeled fields follow later. No extra class or hr is needed, and the fields retain their natural widths.

NumberInput includes its own Field. Use Field around native inputs, ComboBox, and boxed Toggle, and spread the supplied control attributes to connect the label and hint. Help appears beside the label as a separate button.

Fields size to their control, label and hint. Related Width and Height values use fields equal, so both grow to fit the wider hint while independent settings keep their own widths. NumberInput starts at 8em; the group does not fill the parent's spare width.

Paper format
mm

Measured before cutting.

mm

FieldsDemo.svelte

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

	let width = $state<number | null>(210);
	let height = $state<number | null>(297);
</script>

<fieldset class="field-group stack">
	<legend class="field-head">
		<span>Paper format</span>
		<Help
			label="Help: Paper format"
			text="The sheet size before cutting. Each field describes one dimension of the sheet."
		/>
	</legend>
	<div class="fields equal">
		<NumberInput
			label="Width"
			unit="mm"
			bind:value={width}
			min={1}
			help="Enter the width of the uncut sheet. It can differ from the final page size."
			hint="Measured before cutting."
		/>
		<NumberInput label="Height" unit="mm" bind:value={height} min={1} />
	</div>
</fieldset>

Match adjacent controls

When controls sit beside a text or number input, box the neighboring toggles and radio groups too. Use Toggle boxed for a switch and one input-group boxed around the whole radio group. Keep their natural widths. Matching surfaces does not mean stretching every control across the parent.

Infinipose's page-group controls pair Horizontal and Vertical with their columns and rows units, followed by Duplex. The toggle needs no extra heading and sits in a field-accessory so its box centers on the inputs. The related number fields share equal columns and Duplex has a compact auto column. Link and lock icons are compact accessories between fields and keep their existing icon treatment.

columns
rows

PageGroupDemo.svelte

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

	let columns = $state<number | null>(2);
	let rows = $state<number | null>(1);
	let duplex = $state(true);
</script>

<div class="page-group">
	<div class="fields equal">
		<NumberInput label="Horizontal" unit="columns" bind:value={columns} min={1} />
		<NumberInput label="Vertical" unit="rows" bind:value={rows} min={1} />
		<div class="field-accessory">
			<Toggle boxed label="Duplex" bind:checked={duplex} />
		</div>
	</div>
</div>

<style>
	.page-group {
		container-type: inline-size;
	}
	.fields {
		grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
	}
	@container (max-width: 26em) {
		.fields {
			grid-template-columns: minmax(0, 1fr);
		}
		.field-accessory {
			justify-items: start;
		}
	}
</style>

Boxed toggles

Add boxed to Toggle for the same surface and height as a text or number input. Field supplies the top label; Toggle's label supplies the text beside the switch. The entire box is clickable.

For changing text, pass an object with off and on labels. This example sizes itself to the longer text even while the shorter text is visible. Both labels reserve space, so switching states does not resize the box. A field grid or an explicit width can control the width instead.

Enabling this disables all other paper formats.

BoxedToggleDemo.svelte

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

	let custom = $state(true);
</script>

<div class="row">
	<Field label="Custom format" class="hint-wrap" hint="Enabling this disables all other paper formats.">
		{#snippet children(control)}
			<Toggle {...control} boxed label={{ off: 'Disabled', on: 'Enabled' }} bind:checked={custom} />
		{/snippet}
	</Field>
</div>

Boxed radios

Place related Radio options together in one input-group boxed container, with a shared title above it. Use a named group with the shared field rows when aligning choices beside inputs. The shared box matches the adjacent input's height, border, and resting background. Its options wrap when space is limited. Shading and glow stay on the radio indicators; the outer box stays flat, including on focus.

Infinipose places Sheet thickness beside Creep when compensation is enabled. The fields layout keeps each control at its natural width: the number input starts at 8em and the radio box fits its options. A wider label or hint grows only its own field. Each direction's optional explanation belongs on that radio.

Creep
mm

BoxedRadioDemo.svelte

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

	const uid = $props.id();
	let creep = $state('in');
	let thickness = $state<number | null>(0.1);
</script>

<div class="fields">
	<div class="field field-group" role="radiogroup" aria-labelledby="{uid}-creep">
		<div class="field-head" id="{uid}-creep"><span>Creep</span></div>
		<div class="field-control">
			<div class="input-group boxed">
				<Radio name={uid} value="none" label="Off" bind:group={creep} />
				<Radio
					name={uid}
					value="in"
					label="Inwards"
					bind:group={creep}
					{@attach tooltip(
						'Keeps the outermost sheet unchanged and moves the inner pages towards the spine. Starts again in each section.',
						{ delay: 'long' },
					)}
				/>
				<Radio
					name={uid}
					value="out"
					label="Outwards"
					bind:group={creep}
					{@attach tooltip(
						'Keeps the innermost sheet unchanged and moves the outer pages away from the spine. Starts again in each section.',
						{ delay: 'long' },
					)}
				/>
			</div>
		</div>
	</div>
	{#if creep !== 'none'}
		<NumberInput label="Sheet thickness" bind:value={thickness} unit="mm" min={0} step={0.01} />
	{/if}
</div>

Align the controls

The fields grid shares label, control, and hint rows using CSS subgrid. Wrapped labels and optional hints keep the input boxes aligned. A field-accessory places a toggle, link, lock, or action on the control row.

Use fields equal when related inputs should have the same width. This excerpt of Infinipose's Target dimensions keeps Width and Height at their default 8em width, with the link compact in its auto column. A wider label or hint on either field would grow both. The link's optional explanation uses the long delay.

Target dimensions
mm
mm

LinkedFieldsDemo.svelte

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

	let width = $state<number | null>(210);
	let height = $state<number | null>(297);
	let linked = $state(true);
	const ratio = 210 / 297;
	function setWidth(value: number | null) {
		width = value;
		if (linked) height = value === null ? null : Math.round((value / ratio) * 10) / 10;
	}
	function setHeight(value: number | null) {
		height = value;
		if (linked) width = value === null ? null : Math.round(value * ratio * 10) / 10;
	}
</script>

<fieldset class="field-group linked-fields">
	<legend>Target dimensions</legend>
	<div class="fields equal">
		<NumberInput label="Width" unit="mm" min={1} step={0.1} bind:value={() => width, setWidth} />
		<div class="field-accessory">
			<ToggleLink
				aria-label="Keep aspect ratio"
				bind:checked={linked}
				{@attach tooltip('Keep the width-to-height ratio of the source page.', { delay: 'long' })}
			/>
		</div>
		<NumberInput label="Height" unit="mm" min={1} step={0.1} bind:value={() => height, setHeight} />
	</div>
</fieldset>

<style>
	.linked-fields {
		container-type: inline-size;
	}
	.fields {
		grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
	}
	@container (max-width: 22em) {
		.fields {
			grid-template-columns: minmax(0, 1fr);
		}
		.field-accessory {
			justify-items: center;
		}
	}
</style>

Buttons beside inputs

Use fields when controls need to align independently of their labels and hints. A row or flex-hrz centers each whole item, including its label. For buttons without a title, put one button or a btn-group inside a field-accessory, as in the spinner button demo.

Infinipose's page settings place Copies and read-only Rotation beside three rotation actions. The action group has an accessible name without an extra visible heading. Its icons use long-delay tooltips. No fixed heights, empty labels, or vertical offsets are needed. Explicit columns keep the icon group compact. The container query stacks these controls at narrow widths.

°

FieldActionsDemo.svelte

<script lang="ts">
	import { Icon, NumberInput } from '@leon8ix/jhana-ui';
	import { tooltip } from '@leon8ix/jhana-ui/actions';
	import { iconRotate180, iconRotateLeft, iconRotateRight } from './rotation-icons';

	let copies = $state<number | null>(1);
	let rotation = $state(0);
	const actions = [
		{ label: 'Rotate left', angle: -90, icon: iconRotateLeft },
		{ label: 'Rotate by 180°', angle: 180, icon: iconRotate180 },
		{ label: 'Rotate right', angle: 90, icon: iconRotateRight },
	];
</script>

<div class="page-actions">
	<div class="fields">
		<NumberInput label="Copies" bind:value={copies} min={0} />
		<NumberInput label="Rotation" unit="°" value={rotation} readonly />
		<div class="field-accessory">
			<div class="btn-group" role="group" aria-label="Rotate page">
				{#each actions as action}
					<button
						type="button"
						class="btn dark icon icon-root"
						aria-label={action.label}
						onclick={() => (rotation = (rotation + action.angle + 360) % 360)}
						{@attach tooltip(action.label, { delay: 'long' })}
					>
						<Icon
							icon={action.icon}
							width="1.4em"
							height="1.4em"
							hover="var(--cLighter)"
							active="var(--cLighter)"
						/>
					</button>
				{/each}
			</div>
		</div>
	</div>
</div>

<style>
	.page-actions {
		container-type: inline-size;
	}
	.fields {
		grid-template-columns: minmax(0, max-content) minmax(0, max-content) auto;
	}
	@container (max-width: 26em) {
		.fields {
			grid-template-columns: minmax(0, 1fr);
		}
		.field-accessory {
			justify-items: start;
		}
	}
</style>

Units and changing hints

NumberInput starts at 8em. A longer suffix, including a calculated result, grows its natural width only as far as the number and suffix require. Explicit wrapper widths still apply. Keep calculations in the app and pass their result as the unit, without measuring text or estimating a width. Infinipose's Thread sewing toggle is boxed beside Sheets per section. Each sheet contributes four pages.

→ 16 pages

NumberUnitSizingDemo.svelte

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

	let split = $state(true);
	let sheets = $state<number | null>(4);
</script>

<div class="fields">
	<Field label="Thread sewing">
		{#snippet children(control)}
			<Toggle {...control} boxed label="Split into multiple sections" bind:checked={split} />
		{/snippet}
	</Field>
	{#if split}
		<NumberInput label="Sheets per section" bind:value={sheets} min={1} unit={`→ ${(sheets ?? 0) * 4} pages`} />
	{/if}
</div>

Short hints normally contribute to field width. For longer explanations that change with a setting, use hint-wrap on Field or NumberInput. The control and label determine the width and the hint wraps below them. A toggle with a complete side label needs no repeated heading.

Speed and force are embedded in the file and cannot be changed at the machine.

DynamicHintDemo.svelte

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

	let embedded = $state(true);
</script>

<Field
	class="hint-wrap"
	hint={embedded
		? 'Speed and force are embedded in the file and cannot be changed at the machine.'
		: 'Set speed and force on the machine. Check both settings before starting the job.'}
>
	{#snippet children(control)}
		<Toggle {...control} boxed label="Embed speed and force in file" bind:checked={embedded} />
	{/snippet}
</Field>

Separators

Use one hr between sections or columns. It follows the parent layout: horizontal in a stack, vertical between columns, with one 1px line in cDL20 and no default inset. The vertical line stretches to the full flex or grid row, independently of which column is taller. The parent owns the space around it.

Infinipose's grouping dialog separates the booklet controls from Sections in binding order. This example shows a 64-page booklet in four sections of four sheets. The columns fit their contents, and the section cards share the same size. At narrow widths, the layout stacks and the same hr becomes horizontal automatically. The two sides are imported demo components, so the source below only shows the separator layout.

Binding edge
Creep
mm

Sections in binding order

Section 1 4 sheets p. 1–16
Section 2 4 sheets p. 17–32
Section 3 4 sheets p. 33–48
Section 4 4 sheets p. 49–64

4 sections · 16 sheets · 0 additional blank pages per copy

SeparatorsDemo.svelte

<script lang="ts">
	import BookletSectionsDemo from './BookletSectionsDemo.svelte';
	import BookletSettingsDemo from './BookletSettingsDemo.svelte';
</script>

<div class="separator-demo">
	<div class="separator-columns">
		<BookletSettingsDemo />
		<hr />
		<BookletSectionsDemo />
	</div>
</div>

<style>
	.separator-demo {
		container-type: inline-size;
	}
	.separator-columns {
		display: grid;
		inline-size: fit-content;
		max-inline-size: 100%;
		grid-template-columns: minmax(0, max-content) auto minmax(0, max-content);
		align-items: start;
		gap: var(--space-3);
	}
	@container (max-width: 46em) {
		.separator-columns {
			grid-template-columns: minmax(0, 1fr);
		}
	}
</style>

Spacing and scrolling

Use stack for vertical groups and row for wrapping inline content. Containers own gaps; fields own their internal spacing. Override gap directly with a spacing token when needed.

TokenValueUse
--space-10.25emLabel/control and help spacing
--space-20.5emClosely related items
--space-31emGroups and panel padding
--space-41.5emSeparate settings sections

The panel below combines the Binding edge, Creep and Section division controls from Infinipose's booklet settings in a standalone scrolling form. The header and actions stay visible while its body scrolls. Body padding leaves room for focus and hover glows. Change the font size to try proportional UI scaling, as used by Print Meter.

Use cDark behind a form containing cDL05 input surfaces. Giving both the panel and its inputs the same cDL05 background hides the controls' resting surfaces. A card's default background is useful for passive content and selectable cards, but a form container should preserve this distinction.

Booklet settings

Binding edge
Creep
mm
Section division
→ 16 pages
Scroll to see the remaining settings.

SettingsPanelDemo.svelte

<script lang="ts">
	import { Radio } from '@leon8ix/jhana-ui';
	import BoxedRadioDemo from './BoxedRadioDemo.svelte';
	import NumberUnitSizingDemo from './NumberUnitSizingDemo.svelte';

	const uid = $props.id();
	let bindingEdge = $state('left');
	let applied = $state(false);
</script>

<form
	class="panel card nohover settings-panel"
	onsubmit={event => {
		event.preventDefault();
		applied = true;
	}}
>
	<header class="panel-head row">
		<h2 class="h3">Booklet settings</h2>
	</header>
	<div class="panel-body stack" style:gap="var(--space-4)">
		<fieldset class="field-group">
			<legend>Binding edge</legend>
			<div class="input-group boxed">
				<Radio name={uid} value="left" label="Left" bind:group={bindingEdge} />
				<Radio name={uid} value="top" label="Top" bind:group={bindingEdge} />
				<Radio name={uid} value="right" label="Right" bind:group={bindingEdge} />
				<Radio name={uid} value="bottom" label="Bottom" bind:group={bindingEdge} />
			</div>
		</fieldset>
		<BoxedRadioDemo />
		<fieldset class="field-group">
			<legend>Section division</legend>
			<NumberUnitSizingDemo />
		</fieldset>
	</div>
	<footer class="panel-foot row" style:justify-content="flex-end">
		<output class="field-hint flex-1" aria-live="polite"
			>{applied ? 'Example settings applied.' : 'Scroll to see the remaining settings.'}</output
		>
		<button class="btn primary" type="submit">Apply</button>
	</footer>
</form>

<style>
	.settings-panel {
		max-block-size: 28em;
		background-color: var(--cDark);
	}
	.panel-head {
		border-bottom: 1px solid var(--cDL20);
	}
	.panel-foot {
		border-top: 1px solid var(--cDL20);
	}
</style>

The complete contracts and migration recipes ship in @leon8ix/jhana-ui/docs/foundations.md. Existing layout helpers remain available.