diff --git a/src/components/file-picker/file-picker.stories.tsx b/src/components/file-picker/file-picker.stories.tsx
new file mode 100644
index 00000000..13494dc2
--- /dev/null
+++ b/src/components/file-picker/file-picker.stories.tsx
@@ -0,0 +1,109 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import FilePicker from './file-picker';
+
+// FilePicker component story configuration
+const meta: Meta = {
+ title: 'Atoms/FilePicker',
+ component: FilePicker,
+ parameters: {
+ layout: 'centered',
+ },
+ tags: [ 'autodocs' ],
+ argTypes: {
+ size: {
+ control: 'select',
+ options: [ 'sm', 'md', 'lg' ],
+ },
+ },
+ decorators: [
+ ( Story ) => (
+
+
+
+ ),
+ ],
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ size: 'md',
+ label: 'Upload document',
+ disabled: false,
+ error: false,
+ clearable: false,
+ },
+};
+
+export const PrefilledValue: Story = {
+ args: {
+ ...Default.args,
+ value: 'annual-report-2025.pdf',
+ },
+};
+
+export const ControlledFile: Story = {
+ args: {
+ ...Default.args,
+ value: new File( [ 'force-ui' ], 'design-tokens.json', {
+ type: 'application/json',
+ } ),
+ },
+};
+
+export const Multiple: Story = {
+ args: {
+ ...Default.args,
+ multiple: true,
+ value: [
+ new File( [ 'a' ], 'logo-light.svg', { type: 'image/svg+xml' } ),
+ new File( [ 'b' ], 'logo-dark.svg', { type: 'image/svg+xml' } ),
+ ],
+ },
+};
+
+export const Clearable: Story = {
+ args: {
+ ...Default.args,
+ clearable: true,
+ value: 'brand-guidelines.pdf',
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ ...Default.args,
+ disabled: true,
+ },
+};
+
+export const Error: Story = {
+ args: {
+ ...Default.args,
+ error: true,
+ },
+};
+
+export const SizeSm: Story = {
+ args: {
+ ...Default.args,
+ size: 'sm',
+ },
+};
+
+export const SizeMd: Story = {
+ args: {
+ ...Default.args,
+ size: 'md',
+ },
+};
+
+export const SizeLg: Story = {
+ args: {
+ ...Default.args,
+ size: 'lg',
+ },
+};
diff --git a/src/components/file-picker/file-picker.tsx b/src/components/file-picker/file-picker.tsx
new file mode 100644
index 00000000..17a1bc3c
--- /dev/null
+++ b/src/components/file-picker/file-picker.tsx
@@ -0,0 +1,335 @@
+import React, {
+ useState,
+ useEffect,
+ useMemo,
+ forwardRef,
+ useRef,
+ LabelHTMLAttributes,
+} from 'react';
+import { nanoid } from 'nanoid';
+import { cn } from '@/utilities/functions';
+import { Upload, X } from 'lucide-react';
+import Label from '../label';
+import { mergeRefs } from '@/components/toaster/utils';
+
+export declare interface FilePickerProps {
+ /**
+ * Unique identifier for the picker trigger element.
+ *
+ * @since x.x.x
+ */
+ id?: string;
+
+ /**
+ * Controlled display value. Accepts a File, an array of Files, or a
+ * previously saved filename string. Renders in the preview segment as if
+ * the file was selected via the picker.
+ *
+ * @since x.x.x
+ */
+ value?: File | File[] | string | null;
+
+ /**
+ * Defines the size of the picker (e.g., 'sm', 'md', 'lg').
+ *
+ * @since x.x.x
+ */
+ size?: 'sm' | 'md' | 'lg';
+
+ /**
+ * Additional custom classes for styling.
+ *
+ * @since x.x.x
+ */
+ className?: string;
+
+ /**
+ * Disables the picker when true.
+ *
+ * @since x.x.x
+ */
+ disabled?: boolean;
+
+ /**
+ * Function called with the selected FileList, or null when the selection
+ * is cleared.
+ *
+ * @since x.x.x
+ */
+ onChange?: ( files: FileList | null ) => void;
+
+ /**
+ * Indicates whether the picker has an error state.
+ *
+ * @since x.x.x
+ */
+ error?: boolean;
+
+ /**
+ * Label displayed above the picker.
+ *
+ * @since x.x.x
+ */
+ label?: string;
+
+ /**
+ * Indicates whether a file selection is required.
+ *
+ * @since x.x.x
+ */
+ required?: boolean;
+
+ /**
+ * Shows a remove ("X") affordance when a file is selected, replacing the
+ * upload icon. Clicking it clears the selection and fires onChange(null).
+ *
+ * @since x.x.x
+ */
+ clearable?: boolean;
+}
+
+export const FilePickerComponent = (
+ {
+ id,
+ value,
+ size = 'sm', // sm, md, lg
+ className = '',
+ disabled = false,
+ onChange = () => {},
+ error = false,
+ label = '',
+ required = false,
+ clearable = false,
+ 'aria-label': ariaLabel,
+ 'aria-labelledby': ariaLabelledBy,
+ ...props
+ }: FilePickerProps &
+ Omit<
+ React.InputHTMLAttributes,
+ 'size' | 'onChange' | 'value' | 'type'
+ >,
+ ref: React.ForwardedRef
+) => {
+ const inputRef = useRef( null );
+ const pickerId = useMemo( () => id || `file-picker-${ nanoid() }`, [ id ] );
+ // undefined = untouched (fall back to the value prop), null = explicitly cleared.
+ const [ selection, setSelection ] = useState(
+ undefined
+ );
+
+ useEffect( () => {
+ // A changed value prop takes over the display again (controlled replace).
+ setSelection( undefined );
+ }, [ value ] );
+
+ const valueDisplayName = useMemo( () => {
+ if ( value === null || value === undefined ) {
+ return null;
+ }
+ if ( typeof value === 'string' ) {
+ return value || null;
+ }
+ if ( Array.isArray( value ) ) {
+ return value.map( ( file ) => file.name ).join( ', ' ) || null;
+ }
+ return value.name;
+ }, [ value ] );
+
+ const displayName = selection !== undefined ? selection : valueDisplayName;
+
+ const handleChange = ( event: React.ChangeEvent ) => {
+ if ( disabled ) {
+ return;
+ }
+
+ const files = event.target.files;
+ if ( files && files.length > 0 ) {
+ setSelection(
+ Array.from( files )
+ .map( ( file ) => file.name )
+ .join( ', ' )
+ );
+ } else {
+ setSelection( null );
+ }
+
+ if ( typeof onChange !== 'function' ) {
+ return;
+ }
+ onChange( files );
+ };
+
+ const handleReset = () => {
+ // Reset file selection when "X" icon is clicked
+ setSelection( null );
+ if ( inputRef.current ) {
+ inputRef.current.value = '';
+ }
+ onChange( null );
+ };
+
+ const openFileDialog = () => {
+ inputRef.current?.click();
+ };
+
+ const baseClasses =
+ 'bg-field-secondary-background font-normal placeholder-text-tertiary text-text-primary w-full outline outline-1 outline-border-subtle border-none transition-[color,box-shadow,outline] duration-200';
+ const sizeClasses = {
+ sm: 'p-3 py-2 rounded',
+ md: 'p-3.5 py-2.5 rounded-md',
+ lg: 'p-4 py-3 rounded-lg',
+ };
+ const labelClasses = {
+ sm: 'text-sm font-medium',
+ md: 'text-sm font-medium',
+ lg: 'text-base font-medium',
+ };
+ const textClasses = {
+ sm: 'text-xs',
+ md: 'text-sm',
+ lg: 'text-base',
+ };
+
+ const hoverClasses = disabled
+ ? 'hover:outline-border-disabled'
+ : 'hover:outline-border-strong';
+ const focusClasses =
+ 'focus:outline-focus-border focus:ring-2 focus:ring-toggle-on focus:ring-offset-2';
+ const errorFileClasses = error
+ ? 'focus:outline-focus-error-border focus:ring-field-color-error outline-focus-error-border'
+ : '';
+ const disabledUploadFileClasses = disabled
+ ? 'outline-border-disabled cursor-not-allowed text-text-disabled'
+ : '';
+ const uploadIconClasses = disabled
+ ? 'font-normal placeholder-text-tertiary text-icon-disabled pointer-events-none absolute inset-y-0 flex flex-1 items-center'
+ : 'font-normal placeholder-text-tertiary text-field-placeholder pointer-events-none absolute inset-y-0 flex flex-1 items-center';
+
+ const uploadIconSizeClasses = {
+ sm: '[&>svg]:size-4',
+ md: '[&>svg]:size-5',
+ lg: '[&>svg]:size-6',
+ };
+
+ const fileClasses = 'pr-10';
+
+ const renderLabel = useMemo( () => {
+ if ( ! label ) {
+ return null;
+ }
+ return (
+
+ );
+ }, [ label, size, pickerId, required ] );
+
+ const renderSuffix = () => {
+ if ( clearable && displayName && ! disabled ) {
+ return (
+ {
+ if ( e.key === 'Enter' || e.key === ' ' ) {
+ handleReset();
+ }
+ } }
+ >
+
+
+ );
+ }
+ return (
+
+
+
+ );
+ };
+
+ return (
+
+ { renderLabel }
+
+
+ { renderSuffix() }
+
+
+
+ );
+};
+
+const FilePicker = forwardRef( FilePickerComponent );
+FilePicker.displayName = 'FilePicker';
+
+export default FilePicker;
diff --git a/src/components/file-picker/index.ts b/src/components/file-picker/index.ts
new file mode 100644
index 00000000..0fc35fb7
--- /dev/null
+++ b/src/components/file-picker/index.ts
@@ -0,0 +1 @@
+export { default } from './file-picker';
diff --git a/src/components/index.ts b/src/components/index.ts
index 9a302428..b2c79e97 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -41,4 +41,5 @@ export { default as AreaChart } from './area-chart';
export { default as Dropzone } from './dropzone';
export { default as Table } from './table';
export { default as FilePreview } from './file-preview';
+export { default as FilePicker } from './file-picker';
export { default as Text } from './text';
diff --git a/src/components/input/input.tsx b/src/components/input/input.tsx
index 2d5e12bd..61a5e77a 100644
--- a/src/components/input/input.tsx
+++ b/src/components/input/input.tsx
@@ -11,6 +11,7 @@ import { nanoid } from 'nanoid';
import { cn } from '@/utilities/functions';
import { Upload, X } from 'lucide-react';
import Label from '../label';
+import FilePicker from '../file-picker';
import { mergeRefs } from '@/components/toaster/utils';
export declare interface InputProps {
@@ -169,15 +170,9 @@ export const InputComponent = (
const errorClasses = error
? 'focus:outline-focus-error-border focus:ring-field-color-error outline-focus-error-border'
: '';
- const errorFileClasses = error
- ? 'focus:outline-focus-error-border focus:ring-field-color-error outline-focus-error-border'
- : '';
const disabledClasses = disabled
? 'outline-border-disabled bg-field-background-disabled cursor-not-allowed text-text-disabled'
: '';
- const disabledUploadFileClasses = disabled
- ? 'outline-border-disabled cursor-not-allowed text-text-disabled file:text-text-tertiary'
- : '';
const iconClasses =
'font-normal placeholder-text-tertiary text-text-primary pointer-events-none absolute inset-y-0 flex flex-1 items-center [&>svg]:h-4 [&>svg]:w-4';
const uploadIconClasses = disabled
@@ -266,52 +261,21 @@ export const InputComponent = (
);
}, [ label, size, inputId ] );
- const fileClasses = selectedFile
- ? 'file:border-0 file:bg-transparent pr-10'
- : 'text-text-tertiary file:border-0 file:bg-transparent pr-10';
-
if ( type === 'file' ) {
return (
-
+
);
}