Forms
FileField
A labeled file input with a drop zone, on the Field contract. Files can be chosen with the system picker (or a phone's camera, with capture) or dropped on the zone; the chosen ones are listed with a thumbnail for images and a remove button, and they stay in the native input, so a plain form submits them. accept, maxSize and multiple filter what comes in; a file that is left out gets a message under the field (rejectMessage translates it), and onReject reports it.
In Spoonjoy
Spoonjoy, a recipe app, is built with Ouro HX (how).
In its source: app/components/recipe/RecipeImportPanel.tsx · app/components/recipe/RecipePhotoStudio.tsx · app/components/recipe/SpoonDialog.tsx
Install
Once per app, with Tailwind CSS v4 and an @/* path alias (Ouro HX is built on Base UI, so --base base; skip -t in an existing app):
npx shadcn@latest init --base base --preset nova --yes --no-monoreponpx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.2.13Then the component. From v0.8.0 it installs only the helpers it imports and never touches your stylesheet (Installing):
npx shadcn@latest add ourostack/ouro-ui/file-field#v1.2.13Or, with the @ouro namespace set up:
npx shadcn@latest add @ouro/ouro-base @ouro/file-fieldIts tests, stories and Playwright spec are optional. They need Vitest 4.1.11 or later, and from v0.8.0 never change a version your app has (Testing):
npx shadcn@latest add ourostack/ouro-ui/file-field#v1.2.13 ourostack/ouro-ui/file-field-tests#v1.2.13import { FileField } from "@/components/ouro/file-field/file-field";Paths use shadcn's default @/ alias; the CLI rewrites them to yours.
Example
<FileField label="Recipe photo" name="photo" accept="image/*" maxSize={5 * 1024 * 1024} error={errors?.photo} />
<FileField label="Attachments" name="attachments" multiple maxSize={10 * 1024 * 1024} onReject={(rejected) => logRejected(rejected)} />Use instead when
- Use
TextField type="url"instead to take a link to a file, and an upload service's own widget when files go straight to storage without a form.
Props
Type: FileFieldProps.
accept- Type:
string - The files it takes, as the input's
accept: extensions and MIME types, comma-separated (".pdf,image/*"). Files that do not match are left out, with a message under the field, and reported toonReject. capture- Type:
"user" | "environment" - Which camera a phone opens instead of the file picker:
environment(back) oruser(front). Pass it withaccept="image/*". className- Type:
string - Layout only: margins, width, grid placement (
mt-4). Appearance classes warn in development; style fields in your theme. description- Type:
ReactNode - Supporting text under the label ("PNG or JPG, up to 5 MB"), read through
aria-describedby. disabled- Type:
boolean - Disables the input and the drop zone, and fades the field.
error- Type:
ReadonlyErrorMessages - What is wrong, from your server action or client check. It shows under the field, and the input gets
aria-invalid. A message is text or a React node (one with a link); an empty string or array means valid. hideLabel- Type:
boolean - Hides the label visually; screen readers still read it.
label(required)- Type:
string - What the files are for ("Recipe photo"), shown above the drop zone and read as the input's name. Required: every control needs a name. To hide it visually, keep it and add
hideLabel. maxSize- Type:
number - The largest file it takes, in bytes:
5 * 1024 * 1024for 5 MB. Larger files are left out, with a message under the field, and reported toonReject. multiple- Type:
boolean - Takes several files; each new choice or drop adds to the list. Without it, a new file replaces the old one.
name- Type:
string - The form field name the files submit under.
onFilesChange- Type:
((files: File[]) => void) - Called with the chosen files whenever the list changes: a choice, a drop, or a removal.
onReject- Type:
((rejections: FileRejection[]) => void) - Called with the files that were left out, and why. The field already says why under itself; use this to log or count them.
prompt- Type:
string - Default:
"Drag a file here, or choose one"("Drag files here, or choose them" withmultiple) - The text in the drop zone. Translate it with your app.
ref- Type:
Ref<HTMLInputElement> - The file input element.
rejectMessage- Type:
((rejection: FileRejection) => string) - Default: (rejection) => "notes.heic is not an accepted file type.", "big.png is larger than 5 MB." or "Only one file can be added, so b.png was left out."
- The message under the field for each file that was left out, until the next choice takes every file, a removal or a form reset. It is announced to screen readers as a status, and shows next to
errorand a Form's error for this name, never instead of them. Translate it with your app. removeLabel- Type:
string - Default:
"Remove" - The start of each file's remove button name, followed by the file name, as visually hidden text and not an
aria-label, so a test'sgetByLabel("Photo")finds the field and not "Remove photo.jpg". Translate it with your app. required- Type:
boolean - A file must be chosen before the form submits.
Don't use
These are not props. Passing one fails the type check with the fix in the error, and in development a console message names the fix.
acceptedFileTypes: Not a prop. Ouro UI usesaccept, as the input does:<FileField accept="image/*,.pdf">.errorMessage: Not a prop. Ouro UI useserror:<FileField error="Choose a photo.">.helperText: Not a prop. Ouro UI usesdescription:<FileField description="...">.invalid: Not a prop.errormarks the field invalid:<FileField error="What is wrong">.isDisabled: Not a prop. Ouro UI usesdisabled:<FileField disabled>.isInvalid: Not a prop.errormarks the field invalid:<FileField error="What is wrong">.isRequired: Not a prop. Ouro UI usesrequired:<FileField required>.maxFileSize: Not a prop. Ouro UI usesmaxSize, in bytes:<FileField maxSize={5 * 1024 * 1024}>.onChange: Not a prop. Ouro UI usesonFilesChange, which gets the files:<FileField onFilesChange={(files) => ...}>.onDrop: Not a prop. Dropped files arrive inonFilesChange:<FileField onFilesChange={(files) => ...}>.
className is for layout only
Pass margins, width and grid placement (mt-4, w-full, col-span-2). Your classes always win over Ouro UI's: FileField merges className last with cn (tailwind-merge), so a class that conflicts with its own replaces it.layout-classes
Appearance comes from props, not classes. In development, a class that sets appearance logs one warning per component instance, naming the prop to use instead. A class sets appearance when, after its variants (hover:, sm:) and any ! are removed, it starts with bg-, text-, border-, rounded-, shadow-, font-, ring-, from-, via-, to-, fill- or stroke-, or is border, rounded, shadow or ring, or has a dark: variant. Text wrapping, overflow and table layout classes (text-wrap, text-ellipsis, border-collapse) are layout and do not warn.
Alignment classes (text-center) warn too: set alignment on a parent element.
Types
ErrorMessage
One error message: text, or a React element for a message that carries a link or other markup.
type ErrorMessage = string | ReactElement;FileRejection
A file FileField left out, and why: it did not match accept, was larger than maxSize, or was one too many without multiple.
interface FileRejection {
/** The file that was left out. */
file: File;
/** `type`: it does not match `accept`. `size`: it is larger than `maxSize`. `count`: the field takes one file. */
reason: "type" | "size" | "count";
}ReadonlyErrorMessages
A read-only message view accepted by fields and Form; mutable input arrays remain valid.
type ReadonlyErrorMessages = ErrorMessage | readonly ErrorMessage[];Claim sources
- layout-classes: test:registry/ouro/components/ouro/file-field/file-field.test.tsx::FileField > forwards its ref to the input and lets layout classes win on the root
verify:testExists(registry/ouro/components/ouro/file-field/file-field.test.tsx::FileField > forwards its ref to the input and lets layout classes win on the root)