sveltelean Primitives
Versionv0.2.0 GitHub

Example

Attachments

Images or PDF files.

<label for="file-field-attachments">Attachments</label>
<input
	id="file-field-attachments"
	type="file"
	name="attachments"
	data-slean="file-field"
	accept="image/*,.pdf"
	multiple
	aria-describedby="file-field-attachments-hint"
/>
<p id="file-field-attachments-hint">Images or PDF files.</p>
Tier 0: the two sources differ only by the stylesheet import. The button label and the file names are the browser’s, in the reader’s language. Everything here works with page JavaScript disabled.

Why this implementation exists

A common file control hides the input and styles a label as its button. It then has to rebuild what the input showed: the chosen file names, the focus ring and the keyboard path, usually with a script that reads files on every change.

::file-selector-button styles the input’s own button instead. The input stays visible, focusable and labelled, and the browser keeps showing what was chosen.

The browser owns

  • the file chooser, filtered by accept, with several files under multiple
  • the camera on mobile devices for capture
  • the button label and the chosen file names, localized
  • form participation (multipart/form-data) and the label association

Svelte Lean owns

  • file-field.css: the box and the selector button, styled through ::file-selector-button
  • the contract
  • documentation

Usage

The markup needs no package. Install @svelte-lean/styles for the stylesheet; @svelte-lean/primitives adds the typed contract and nothing at runtime.

npm install @svelte-lean/styles
stylesheets
import '@svelte-lean/styles/file-field.css';

Give the input a name and submit it in a form with method="post" and enctype="multipart/form-data", or read its files in the application.

accept filters the chooser by MIME type or extension. It is not validation, since the chooser can show all files: check the type on the server. multiple allows several files; capture asks a mobile browser for the camera.

Anatomy

PartElementdata-slean-partRequiredNotes
root<input type="file" data-slean="file-field">–yesA label and a name; accept, multiple and capture as needed.
selector button::file-selector-button–styles onlyThe browser’s button inside the input; its label cannot be changed from CSS.

Runtime profile

Tier 0: the field has no behavior module, and the Vite plugin maps file-field to no module. Choosing, filtering and submitting are the browser’s.

Accessibility contract

  • A label through <label for>; accepted types and limits through aria-describedby.
  • The input is one tab stop; Enter or Space opens the chooser, which belongs to the operating system.
  • The focus ring is the shared one, drawn around the whole field.
  • :user-invalid marks a required field left empty only after the user interacted with it.

Keyboard

KeyWhenResult
Tab/Shift+TabanywhereMoves focus to and from the field (native)
Enter/Spacefocus on the fieldOpens the file chooser, which has its own keyboard (native)

Platform features

FeatureBaselineOutside the target
<input type="file">, multiple, acceptWidely availableNot applicable
::file-selector-buttonBaseline 2021The browser’s default button inside the styled box
captureHonored by mobile browsersDesktop browsers open the file chooser

Without JavaScript

Fully functional: choosing files and submitting them with a form need no script.

Server rendering

Static HTML. A file input cannot have an initial value; the server renders it empty.

Before hydration

Nothing is attached; the field works before and after hydration alike.

Styling

file-field.css draws the box at the control height of the other fields, styles ::file-selector-button as a quiet button inside it and grows both on coarse pointers. The button label and the file name text cannot be replaced from CSS. The tokens it reads:

TokenDefault (light)Applies to
--slean-control-height-md2.25remmin-block-size
--slean-space-10.25rempadding, min-block-size, border-radius
--slean-space-30.75rempadding-inline-end, margin-inline, padding-inline
--slean-control-bordervar(--slean-border-strong)border
--slean-radius-md0.625remborder-radius
--slean-control-bgvar(--slean-surface)background
--slean-fg-mutedvar(--slean-neutral-11)color
--slean-text-sm0.875remfont-size
--slean-duration-fast100mstransition
--slean-easecubic-bezier(0.2, 0, 0, 1)transition
--slean-mutedvar(--slean-neutral-3)background
--slean-fgvar(--slean-neutral-12)color
--slean-font-weight-medium500font-weight
--slean-control-border-hovervar(--slean-accent)border-color
--slean-control-border-focusvar(--slean-accent)border-color
--slean-control-ring0 0 0 3px color-mix(in oklch, var(--slean-accent) 22%, transparent)box-shadow
--slean-warningoklch(76% 0.16 80)border-color
--slean-control-ring-warning0 0 0 3px color-mix(in oklch, var(--slean-warning) 28%, transparent)box-shadow
--slean-dangeroklch(55% 0.2 25)border-color
--slean-control-ring-danger0 0 0 3px color-mix(in oklch, var(--slean-danger) 22%, transparent)box-shadow
--slean-control-border-disabledvar(--slean-border)border-color
--slean-control-bg-disabledvar(--slean-muted)background-color
--slean-muted-hovervar(--slean-neutral-4)background
--slean-text-md1remfont-size

State selectors the stylesheet targets, all from the platform or ARIA: ::file-selector-button, :disabled, :focus-visible, :hover, :user-invalid, [aria-invalid="true"].

Variant attributes: data-status (warning, error).

Controlled integration

The chosen files are the input’s files. bind:files gives the application the FileList to list, preview or upload; the field renders nothing beyond the browser’s summary.

attachments.svelte
<script lang="ts">
	let files = $state<FileList>();
</script>

<input id="file-field-attachments" type="file" name="attachments" data-slean="file-field"
	multiple bind:files />

{#if files?.length}
	<ul>
		{#each files as file (file.name)}
			<li>{file.name}</li>
		{/each}
	</ul>
{/if}

Compatibility notes

::file-selector-button is Baseline 2021; where it is missing, the browser’s own button appears inside the styled box. The package adds no drop zone: dropping a file onto the input itself is the browser’s behavior where it exists, and a larger drop area needs dragover and drop listeners, which this Tier 0 primitive does not ship.

Examples

Camera

capture asks a mobile browser to open the camera instead of the chooser; environment is the rear camera, user the front one. Desktop browsers ignore it and open the file chooser.

capture
<!-- On a phone, capture offers the camera directly; desktop browsers open the chooser. -->
<label for="file-field-receipt">Receipt photo</label>
<input
	id="file-field-receipt"
	type="file"
	name="receipt"
	data-slean="file-field"
	accept="image/*"
	capture="environment"
/>

Testing

Source