All components
File Upload
Dashed dropzone with a file item list showing names, sizes, and delete triggers.
Attachments
- brand-guidelines.pdf1.2 MB
- logo.svg18 KB
1234567891011121314151617181920212223242526272829303132import { FileUpload } from "#components/ui/base-ui/file-upload.tsx";
export function Example() { return ( // Base UI has no file-upload machine, so Root owns the file list; the // Dropzone opens a real file dialog (and accepts drops), and parts // compose through render <FileUpload.Root maxFiles={5}> <FileUpload.Label>Attachments</FileUpload.Label> <FileUpload.Dropzone> <FileUpload.DropzoneContent> Drag files here or click to browse </FileUpload.DropzoneContent> </FileUpload.Dropzone> <FileUpload.ItemGroup> <FileUpload.Context> {(api) => api.files.map((file) => ( <FileUpload.Item key={file.name} file={file}> <FileUpload.ItemContent> <FileUpload.ItemName /> <FileUpload.ItemSizeText /> </FileUpload.ItemContent> <FileUpload.ItemDeleteTrigger /> </FileUpload.Item> )) } </FileUpload.Context> </FileUpload.ItemGroup> </FileUpload.Root> );}Install
1npx stylexblocks add file-uploadSource
components/ui/base-ui/file-upload.tsx
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413import * as stylex from "@stylexjs/stylex";import { createContext, useContext, useMemo, useRef, useState } from "react";import { createSlotRecipeContext } from "#lib/base-ui/create-slot-recipe-context.tsx";import { defineSlotRecipe, resolveSlotStyles, type StyleOverrideProps,} from "#lib/recipe-props.ts";import { colorPalette } from '@stylexblocks/ui/color-palette.stylex.ts'import { durations } from '@stylexblocks/ui/tokens/durations.stylex.ts'import { fontSizes } from '@stylexblocks/ui/tokens/font-sizes.stylex.ts'import { fontWeights } from '@stylexblocks/ui/tokens/font-weights.stylex.ts'import { lineHeights } from '@stylexblocks/ui/tokens/line-heights.stylex.ts'import { semanticColors } from '@stylexblocks/ui/semantic-tokens/colors.stylex.ts'import { semanticRadii } from '@stylexblocks/ui/semantic-tokens/radii.stylex.ts'import { sizes } from '@stylexblocks/ui/tokens/sizes.stylex.ts'import { spacing } from '@stylexblocks/ui/tokens/spacing.stylex.ts'
const fileUploadRoot = stylex.create({ base: { display: 'flex', flexDirection: 'column', gap: spacing._4, width: '100%', alignItems: 'flex-start', },})
const fileUploadLabel = stylex.create({ base: { fontWeight: fontWeights.medium, fontSize: fontSizes.sm, lineHeight: lineHeights.sm, },})
const fileUploadDropzone = stylex.create({ base: { backgroundColor: semanticColors.bg, borderRadius: semanticRadii.panel, borderWidth: '1px', borderStyle: 'dashed', display: 'flex', alignItems: 'center', flexDirection: 'column', gap: spacing._4, justifyContent: 'center', padding: spacing._4, transition: 'backgrounds', outlineWidth: { default: null, ':focus-visible': '1px', }, outlineOffset: { default: null, ':focus-visible': '2px', }, outlineStyle: { default: null, ':focus-visible': 'solid', }, outlineColor: { default: null, ':focus-visible': colorPalette.focusRing, }, },})
const fileUploadDropzoneContent = stylex.create({ base: { display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center', gap: spacing._4, fontSize: fontSizes.sm, lineHeight: lineHeights.sm, },})
const fileUploadItem = stylex.create({ base: { fontSize: fontSizes.sm, lineHeight: lineHeights.sm, animationName: 'fade-in', animationDuration: durations.moderate, backgroundColor: semanticColors.bg, borderRadius: semanticRadii.panel, borderWidth: '1px', width: '100%', display: 'flex', alignItems: 'center', gap: spacing._3, paddingInline: spacing._4, paddingBlock: spacing._2, },})
const fileUploadItemGroup = stylex.create({ base: { width: '100%', display: 'flex', flexDirection: 'column', gap: spacing._3, },})
const fileUploadItemName = stylex.create({ base: { color: semanticColors.fg, fontWeight: fontWeights.medium, lineClamp: 1, },})
const fileUploadItemContent = stylex.create({ base: { display: 'flex', flexDirection: 'column', gap: spacing._0_5, flex: 1, },})
const fileUploadItemSizeText = stylex.create({ base: { color: semanticColors.fgMuted, fontSize: fontSizes.xs, lineHeight: lineHeights.xs, },})
const fileUploadItemDeleteTrigger = stylex.create({ base: { alignSelf: 'flex-start', },})
const fileUploadItemPreviewImage = stylex.create({ base: { width: sizes._10, height: sizes._10, objectFit: 'scale-down', },})
const fileUploadRecipe = defineSlotRecipe({ slots: { root: { styles: fileUploadRoot }, label: { styles: fileUploadLabel }, dropzone: { styles: fileUploadDropzone }, dropzoneContent: { styles: fileUploadDropzoneContent }, item: { styles: fileUploadItem }, itemGroup: { styles: fileUploadItemGroup }, itemName: { styles: fileUploadItemName }, itemContent: { styles: fileUploadItemContent }, itemSizeText: { styles: fileUploadItemSizeText }, itemDeleteTrigger: { styles: fileUploadItemDeleteTrigger }, itemPreviewImage: { styles: fileUploadItemPreviewImage }, }, defaultVariants: {},});
const chrome = stylex.create({ btnReset: { appearance: "none", backgroundColor: "transparent", borderWidth: 0, borderStyle: "none", padding: 0, margin: 0, fontFamily: "inherit", fontSize: "inherit", color: "inherit", cursor: "pointer", textAlign: "inherit", }, listReset: { listStyle: "none", margin: 0, padding: 0 }, hiddenInput: { position: "absolute", width: "1px", height: "1px", overflow: "hidden", clipPath: "inset(50%)", whiteSpace: "nowrap", },});
function CloseIcon(props: React.ComponentProps<"svg">) { return ( <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true" {...props}> <path d="m4 4 8 8m0-8-8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /> </svg> );}
/** A real File or a lightweight stand-in (useful for SSR demos). */export interface FileUploadFileLike { name: string; size: number;}
function formatBytes(size: number) { if (size < 1024) return `${size} B`; if (size < 1024 * 1024) return `${Math.round(size / 1024)} KB`; return `${(size / (1024 * 1024)).toFixed(1)} MB`;}
interface FileUploadContextValue { files: FileUploadFileLike[]; addFiles: (files: FileUploadFileLike[]) => void; removeFile: (file: FileUploadFileLike) => void;}
const FileUploadContext = createContext<FileUploadContextValue>({ files: [], addFiles: () => {}, removeFile: () => {},});
const ItemContext = createContext<FileUploadFileLike>({ name: "", size: 0 });
const { withProvider, withContext } = createSlotRecipeContext(fileUploadRecipe);
export interface FileUploadRootProps extends Omit<React.ComponentProps<"div">, "style">, StyleOverrideProps { /** Prefilled file rows (real Files or name/size stand-ins). */ defaultFiles?: FileUploadFileLike[]; maxFiles?: number; onFilesChange?: (files: FileUploadFileLike[]) => void;}
const StyledRoot = withProvider("div", "root", "FileUploadRoot");
/** * FileUpload — dashed dropzone with a file item list. Base UI ships no * file-upload machine, so Root owns the file list; the Dropzone opens a real * file dialog (and accepts drops), and parts compose through `render`. */function Root(props: FileUploadRootProps) { const { defaultFiles = [], maxFiles, onFilesChange, ...rest } = props; const [files, setFiles] = useState<FileUploadFileLike[]>(defaultFiles); const ctx = useMemo<FileUploadContextValue>(() => { const commit = (next: FileUploadFileLike[]) => { setFiles(next); onFilesChange?.(next); }; return { files, addFiles: (added: FileUploadFileLike[]) => { const merged = [...files]; for (const file of added) { if (!merged.some((f) => f.name === file.name)) merged.push(file); } commit(maxFiles != null ? merged.slice(0, maxFiles) : merged); }, removeFile: (file: FileUploadFileLike) => commit(files.filter((f) => f.name !== file.name)), }; }, [files, maxFiles, onFilesChange]); return ( <FileUploadContext.Provider value={ctx}> <StyledRoot {...rest} /> </FileUploadContext.Provider> );}Root.displayName = "FileUploadRoot";
const StyledDropzone = withContext( "button", (v) => [chrome.btnReset, ...resolveSlotStyles(fileUploadRecipe.slots.dropzone, v)], "FileUploadDropzone",);
/** Opens the file dialog on click; accepts dragged files. */function Dropzone( props: React.ComponentProps<"button"> & StyleOverrideProps,) { const ctx = useContext(FileUploadContext); const inputRef = useRef<HTMLInputElement>(null); const { children, onClick, onDragOver, onDrop, ...rest } = props; return ( <> <StyledDropzone type="button" onClick={(event: React.MouseEvent<HTMLButtonElement>) => { onClick?.(event); inputRef.current?.click(); }} onDragOver={(event: React.DragEvent<HTMLButtonElement>) => { onDragOver?.(event); event.preventDefault(); }} onDrop={(event: React.DragEvent<HTMLButtonElement>) => { onDrop?.(event); event.preventDefault(); ctx.addFiles(Array.from(event.dataTransfer.files)); }} {...rest} > {children} </StyledDropzone> <input ref={inputRef} type="file" multiple tabIndex={-1} aria-hidden="true" onChange={(event) => { ctx.addFiles(Array.from(event.target.files ?? [])); event.target.value = ""; }} {...stylex.props(chrome.hiddenInput)} /> </> );}Dropzone.displayName = "FileUploadDropzone";
const StyledItem = withContext("li", "item", "FileUploadItem");
export interface FileUploadItemProps extends Omit<React.ComponentProps<"li">, "style">, StyleOverrideProps { file: FileUploadFileLike;}
function Item(props: FileUploadItemProps) { const { file, ...rest } = props; return ( <ItemContext.Provider value={file}> <StyledItem {...rest} /> </ItemContext.Provider> );}Item.displayName = "FileUploadItem";
const StyledItemName = withContext("span", "itemName", "FileUploadItemName");
function ItemName(props: React.ComponentProps<"span"> & StyleOverrideProps) { const file = useContext(ItemContext); const { children, ...rest } = props; return <StyledItemName {...rest}>{children ?? file.name}</StyledItemName>;}ItemName.displayName = "FileUploadItemName";
const StyledItemSizeText = withContext("span", "itemSizeText", "FileUploadItemSizeText");
function ItemSizeText(props: React.ComponentProps<"span"> & StyleOverrideProps) { const file = useContext(ItemContext); const { children, ...rest } = props; return ( <StyledItemSizeText {...rest}>{children ?? formatBytes(file.size)}</StyledItemSizeText> );}ItemSizeText.displayName = "FileUploadItemSizeText";
const StyledItemDeleteTrigger = withContext( "button", (v) => [chrome.btnReset, ...resolveSlotStyles(fileUploadRecipe.slots.itemDeleteTrigger, v)], "FileUploadItemDeleteTrigger",);
function ItemDeleteTrigger( props: React.ComponentProps<"button"> & StyleOverrideProps,) { const ctx = useContext(FileUploadContext); const file = useContext(ItemContext); const { children, onClick, ...rest } = props; return ( <StyledItemDeleteTrigger type="button" aria-label={`Remove ${file.name}`} onClick={(event: React.MouseEvent<HTMLButtonElement>) => { onClick?.(event); ctx.removeFile(file); }} {...rest} > {children ?? <CloseIcon />} </StyledItemDeleteTrigger> );}ItemDeleteTrigger.displayName = "FileUploadItemDeleteTrigger";
/** Read the file list the way Ark exposes its machine context. */function Context({ children,}: { children: (ctx: FileUploadContextValue) => React.ReactNode;}) { return children(useContext(FileUploadContext));}
export const FileUpload = { Root, Label: withContext("span", "label", "FileUploadLabel"), Dropzone, DropzoneContent: withContext("span", "dropzoneContent", "FileUploadDropzoneContent"), ItemGroup: withContext( "ul", (v) => [...resolveSlotStyles(fileUploadRecipe.slots.itemGroup, v), chrome.listReset], "FileUploadItemGroup", ), Item, ItemContent: withContext("div", "itemContent", "FileUploadItemContent"), ItemName, ItemSizeText, ItemDeleteTrigger, Context,};