Skip to content

A Finder for the web, over any filesystem

A Svelte 5 file browser. Every window here shares one in-browser filesystem: type in the terminal, double-click a file to edit it, publish a post from the browser.

bun add svelte-finder
Read the docs
Start
  • Documentation
  • Docs
    Columns with a file preview, icons and list views, search, and two commands that update both Finder windows live. Recorded from the demos on this page.

    Install

    One package, written with runes for Svelte 5. Its only peer dependency is svelte ^5.40. The adapters describe the slice of each backend they call as their own types, so modern-monaco and just-bash are only needed if you use them.

    shell
    bun add svelte-finder
    ts
    import { Finder, memoryFS, modernMonacoFS, justBashFS } from 'svelte-finder'
    import type { FinderEntry, FinderFS, FinderView } from 'svelte-finder'

    Quick start

    Give the Finder a filesystem and a box to fill. It sizes to its container, so give that a height. memoryFS takes a nested object: a string or Uint8Array is a file, an object is a directory. The Finder opens at / in the columns view, with a toolbar, a search field and a status bar.

    +page.svelte
    <script lang="ts">
      import { Finder, memoryFS } from 'svelte-finder';
    
      const fs = memoryFS({
        'README.md': '# Hello\n',
        src: {
          'app.ts': 'export {};\n',
          lib: { 'util.ts': 'export const id = <T>(x: T) => x;\n' }
        },
        'logo.png': new Uint8Array(2048)
      });
    </script>
    
    <div style="height: 420px">
      <Finder {fs} />
    </div>

    The object returned by memoryFS also writes, moves and reads. Every change notifies the Finder through watch, so it updates without being told.

    ts
    fs.write('/src/lib/new.ts', 'export {};\n'); // parents are created
    fs.mkdir('/docs');
    await fs.move('/docs', '/src/docs');
    fs.remove('/logo.png');
    await fs.read('/README.md'); // the bytes of '# Hello\n'

    Views

    Three ways to look at a directory, as in macOS: icons is a grid, list is a table with size and date, columns keeps every parent open to the left. bind:view drives it from outside and hears the toolbar switch. bind:path does the same for the open directory: open a folder in the Finder and the path above follows, set it and the Finder goes there. In the list view group='modified' sorts the folder under Finder's date headings: Today, Yesterday, Previous 7 Days, Previous 30 Days, this year's months, then years. It is built on Temporal: Chrome and Firefox have it, Safari does not yet, and there the list stays flat.

    /static/images

    Go to folder

    ViewsDemo.svelte
    <script lang="ts">
    	import { onMount } from 'svelte';
    	import { Finder, memoryFS, type FinderView } from 'svelte-finder';
    
    	import Demo from './Demo.svelte';
    	import { addPhotos, project } from './project.js';
    	import Button from './ui/Button.svelte';
    	import ToggleGroup from './ui/ToggleGroup.svelte';
    
    	// spread the files over the last few years so date grouping has something to show
    	const DAYS_AGO = [0, 1, 4, 12, 45, 120, 400, 900];
    	const NOW = Date.now();
    	const modified = (path: string) =>
    		NOW -
    		DAYS_AGO[[...path].reduce((sum, c) => sum + c.charCodeAt(0), 0) % DAYS_AGO.length] * 86_400_000;
    	const fs = memoryFS(project, { modified });
    	onMount(() => addPhotos(fs, modified));
    	const VIEWS: FinderView[] = ['icons', 'list', 'columns'];
    
    	let view = $state<FinderView>('icons');
    	let path = $state('/static/images');
    	let grouped = $state(false);
    </script>
    
    <Demo>
    	{#snippet controls()}
    		<ToggleGroup label="View" items={VIEWS} bind:value={view} />
    		<Button
    			variant="ghost"
    			size="sm"
    			aria-pressed={grouped}
    			onclick={() => {
    				grouped = !grouped;
    				if (grouped) view = 'list';
    			}}>By date</Button
    		>
    		<code class="ml-auto truncate px-2 font-mono text-[12.5px] text-muted-foreground">{path}</code>
    	{/snippet}
    	<Finder
    		{fs}
    		rootLabel="field-notes"
    		bind:view
    		bind:path
    		group={grouped ? 'modified' : null}
    		class="h-full"
    	/>
    </Demo>

    Selection and events

    A click selects and bind:selected holds the path, or null. A double-click or Enter opens: a folder in place, a file through onopen, which is where you load it into an editor, download it or route to it. In the columns view a click on a folder already opens it as the next column, as in Finder. Back and Forward in the toolbar walk the folders the Finder has shown, and bind:this reaches the same history from code: history.paths, history.index, back(), forward() and go(index) drive the row under this demo. The columns view's pane holds the preview snippet: the selected file, or null and the open folder's path. This one reads the file with fs.read.

    Go to folder

    /src/lib/utils

    Select a file to read it here.

    SelectionDemo.svelte
    <script lang="ts">
    	import { onMount } from 'svelte';
    	import { Finder, memoryFS, type FinderEntry } from 'svelte-finder';
    
    	import Demo from './Demo.svelte';
    	import { addPhotos, project } from './project.js';
    	import Button from './ui/Button.svelte';
    
    	const fs = memoryFS(project);
    	onMount(() => addPhotos(fs));
    	const decoder = new TextDecoder();
    
    	let finder = $state<Finder<FinderEntry>>();
    	let selected = $state<string | null>(null);
    
    	const paths = $derived(finder?.history.paths ?? []);
    	const index = $derived(finder?.history.index ?? -1);
    </script>
    
    <Demo tall>
    	<Finder
    		bind:this={finder}
    		{fs}
    		rootLabel="field-notes"
    		path="/src/lib/utils"
    		bind:selected
    		class="h-full"
    	>
    		{#snippet preview(entry, folder)}
    			<div class="flex h-full flex-col gap-3 p-4">
    				<strong class="truncate font-mono text-[13px] font-medium">{entry?.name ?? folder}</strong>
    				{#if entry}
    					{#await fs.read(entry.path) then bytes}
    						{#if bytes.includes(0)}
    							<p class="text-[12.5px] text-muted-foreground">Binary, {bytes.length} bytes</p>
    						{:else}
    							<pre
    								class="m-0 min-h-0 flex-1 overflow-auto bg-well p-3 font-mono text-[11.5px] leading-[1.6] whitespace-pre-wrap">{decoder.decode(
    									bytes
    								)}</pre>
    						{/if}
    					{/await}
    				{:else}
    					<p class="text-[12.5px] text-muted-foreground">Select a file to read it here.</p>
    				{/if}
    			</div>
    		{/snippet}
    	</Finder>
    	{#snippet footer()}
    		<div class="flex items-center gap-1 px-2 py-2 text-[13px]">
    			<Button variant="ghost" size="sm" disabled={index <= 0} onclick={() => finder?.back()}
    				>Back</Button
    			>
    			<Button
    				variant="ghost"
    				size="sm"
    				disabled={index >= paths.length - 1}
    				onclick={() => finder?.forward()}>Forward</Button
    			>
    			<ol class="flex min-w-0 flex-1 gap-1 overflow-x-auto" aria-label="History">
    				{#each paths as path, i (i)}
    					<li class="shrink-0">
    						<button
    							type="button"
    							class={[
    								'px-2 py-1 font-mono text-[12.5px] hover:bg-muted',
    								i === index ? 'bg-muted text-foreground' : 'text-muted-foreground'
    							]}
    							aria-current={i === index ? 'step' : undefined}
    							onclick={() => finder?.go(i)}>{path}</button
    						>
    					</li>
    				{/each}
    			</ol>
    		</div>
    	{/snippet}
    </Demo>

    Filesystems

    The Finder never touches storage. It asks a FinderFS for one directory at a time, with absolute /-separated paths, and sorts, filters and searches what comes back itself. A backend that can report changes adds watch, and the Finder lists again when it fires. One that cannot leaves it out: wrap it in withChangeFeed and call notify() after you change something. A backend with its own search adds search, and the Finder asks it instead of walking the tree. With read the columns view previews images and text, and with move entries can be dragged onto folders and path levels.

    fs.ts
    interface FinderFS {
      // Entries of the directory at path, in any order; reject if it is not a directory.
      list(path: string): Promise<FinderEntry[]>;
      // Optional: call onChange when anything at or below path changes; return an unsubscribe.
      watch?(path: string, onChange: () => void): () => void;
      // Optional: your own search under root; the signal aborts when the query changes.
      search?(query: string, root: string, opts: { signal: AbortSignal }): Promise<FinderEntry[]>;
      // Optional: the file's bytes; the built-in preview shows images and the start of text files.
      read?(path: string): Promise<Uint8Array>;
      // Optional: move from to its new full path, rejecting if that exists; entries become draggable.
      move?(from: string, to: string): Promise<void>;
    }
    
    interface FinderEntry {
      name: string; // base name, no slashes
      path: string; // absolute: '/', '/src', '/src/app.ts'
      kind: 'file' | 'directory';
      size?: number; // bytes
      modified?: number; // epoch milliseconds
      symlink?: boolean; // a link; kind is what it points at
    }

    So an adapter is one function that maps your backend's listing onto FinderEntry. joinPath and parentPath are exported for the path arithmetic.

    http-fs.ts
    import { joinPath, type FinderFS } from 'svelte-finder';
    
    // A read-only Finder over an API that answers GET /ls?path=/src with
    // [{ "name": "app.ts", "dir": false, "bytes": 812, "mtime": "2026-10-01T09:00:00Z" }]
    export const httpFS = (base: string): FinderFS => ({
      async list(path) {
        const response = await fetch(`${base}/ls?path=${encodeURIComponent(path)}`);
        if (!response.ok) throw new Error(`${path}: ${response.status}`);
        const rows: { name: string; dir: boolean; bytes: number; mtime: string }[] = await response.json();
        return rows.map((row) => ({
          name: row.name,
          path: joinPath(path, row.name),
          kind: row.dir ? 'directory' : 'file',
          size: row.bytes,
          modified: Date.parse(row.mtime)
        }));
      }
    });

    modern-monaco-svelte

    The editor to pair it with is modern-monaco-svelte, Svelte components for modern-monaco. Here a Finder and its MonacoEditor share one Workspace: modernMonacoFS(workspace.fs) lists the workspace, forwards its watch and maps read and move onto it, and a WorkspaceState keeps the open file. Double-click a file and onopen opens it; the editor follows the workspace history and the Finder highlights its file. Type in the editor and the list view's size and date follow. The workspace is stored in IndexedDB, so your edits are still there after a reload. The adapter takes any modern-monaco workspace's fs, with or without the Svelte components. modern-monaco-svelte docs

    Loading modern-monaco…
    MonacoDemo.svelte
    <script lang="ts">
    	import { Finder, modernMonacoFS } from 'svelte-finder';
    	import { Workspace } from 'modern-monaco';
    	import { MonacoEditor, WorkspaceState } from 'modern-monaco-svelte';
    
    	import { isDark } from './dark.js';
    	import Demo from './Demo.svelte';
    
    	// Lives in IndexedDB: your edits survive a reload, initialFiles only seed it once.
    	const workspace = new Workspace({
    		name: 'svelte-finder-demo',
    		initialFiles: {
    			'README.md': '# Workspace\n\nDouble-click a file in the Finder to open it here.\n',
    			'src/main.ts': "import { greet } from './greet';\n\nconsole.log(greet('Finder'));\n",
    			'src/greet.ts': 'export const greet = (name: string) => `Hello, ${name}!`;\n',
    			'styles/app.css': 'body {\n\tmargin: 0;\n\tfont-family: system-ui, sans-serif;\n}\n'
    		},
    		entryFile: 'README.md'
    	});
    	const fs = modernMonacoFS(workspace.fs);
    	// the open file lives in the workspace's history: onopen pushes onto it, the editor follows
    	const files = new WorkspaceState(workspace);
    	$effect(() => () => files.dispose());
    </script>
    
    <Demo tall>
    	<div
    		class="grid h-full grid-rows-2 gap-2.5 min-[700px]:grid-cols-[minmax(0,2fr)_minmax(0,3fr)] min-[700px]:grid-rows-1"
    	>
    		<Finder
    			{fs}
    			rootLabel="workspace"
    			view="list"
    			searchable={false}
    			selected={files.current ? `/${files.current}` : null}
    			onopen={(entry) => files.open(entry.path.slice(1))}
    			class="h-full"
    		/>
    		<MonacoEditor
    			{workspace}
    			file={files.current}
    			followHistory
    			dark={isDark()}
    			options={{ minimap: { enabled: false }, fontSize: 13 }}
    			class="h-full border"
    		/>
    	</div>
    </Demo>

    just-bash

    A Finder over the in-memory filesystem of a just-bash shell, rooted at /project with the root prop. The shell has no change events, so the demo wraps the adapter in withChangeFeed and calls notify() after every command; a drag-and-drop move notifies by itself. Each command runs in /project; cd does not carry over to the next one.

    Loading just-bash…
    BashDemo.svelte
    <script lang="ts">
    	import { Finder, justBashFS, withChangeFeed } from 'svelte-finder';
    	import { Bash } from 'just-bash/browser';
    
    	import Demo from './Demo.svelte';
    	import Shell from './Shell.svelte';
    
    	const bash = new Bash({
    		cwd: '/project',
    		files: {
    			'/project/README.md': '# project\n',
    			'/project/src/index.ts': "export * from './lib';\n",
    			'/project/src/lib.ts': 'export const answer = 42;\n'
    		}
    	});
    	// just-bash has no change events: withChangeFeed adds a watch that fires on notify().
    	const fs = withChangeFeed(justBashFS(bash.fs));
    
    	async function run(line: string) {
    		const { stdout, stderr } = await bash.exec(line);
    		fs.notify();
    		return stdout + stderr;
    	}
    </script>
    
    <Demo tall>
    	<Finder {fs} root="/project" rootLabel="project" view="list" class="h-full" />
    	{#snippet footer()}
    		<Shell
    			class="h-66 sm:h-50"
    			{run}
    			empty="Output appears here."
    			examples={[
    				'mkdir -p docs/notes',
    				'touch docs/todo.md',
    				'echo "hello" > hello.txt',
    				'rm -r src',
    				'ls -la'
    			]}
    		/>
    	{/snippet}
    </Demo>

    Styling

    The Finder ships a neutral light look and reads every colour, font and size from a custom property, each with that look as its fallback. Set them on any ancestor and every Finder inside follows: this page sets them once on :root from its own palette, which is why the demos switch with the theme toggle.

    VariableDefaultPaints
    --finder-bgoklch(1 0 0)Window background, and the search field.
    --finder-fgoklch(0.145 0 0)Names and the current path crumb.
    --finder-muted-fgoklch(0.556 0 0)Sizes, dates, chevrons, the status bar.
    --finder-borderoklch(0.922 0 0)Toolbar, list, column and status bar rules, and the search field.
    --finder-hover-bgoklch(0.97 0 0)Hovered rows, the pressed view button, open folders in columns.
    --finder-selected-bgoklch(0.205 0 0)The selected entry.
    --finder-selected-fgoklch(0.985 0 0)Text on the selected entry; its muted text is mixed from it.
    --finder-accentoklch(0.556 0 0 / 0.2)The fill of folder icons.
    --finder-fontui-sans-serif, system-uiThe toolbar, headers, kinds and dates.
    --finder-monoui-monospace, MenloEntry names and the path crumbs below the root.
    --finder-radius0Corners of the window, rows and buttons.
    --finder-column-width16remWidth of each column in the columns view.
    --finder-icon-size3.5remLarge icons: the icons view and the detail pane.

    A theme

    Warm paper-on-walnut, a serif for names and rounder corners. Nothing but variables on a wrapper div, which overrides the page-wide values because it is closer to the Finder.

    Go to folder

    ThemedDemo.svelte
    <script lang="ts">
    	import { onMount } from 'svelte';
    	import { Finder, memoryFS } from 'svelte-finder';
    
    	import Demo from './Demo.svelte';
    	import { addPhotos, project } from './project.js';
    
    	const fs = memoryFS(project);
    	onMount(() => addPhotos(fs));
    </script>
    
    <Demo>
    	<div class="darkroom">
    		<Finder {fs} rootLabel="field-notes" path="/static/images" />
    	</div>
    </Demo>
    
    <style>
    	.darkroom {
    		--finder-bg: #2b211b;
    		--finder-fg: #f3e7d6;
    		--finder-muted-fg: #b09a86;
    		--finder-border: #f3e7d61f;
    		--finder-hover-bg: #3a2d25;
    		--finder-selected-bg: #e2a55a;
    		--finder-selected-fg: #2b211b;
    		--finder-accent: #e2a55a66;
    		--finder-font: 'Iowan Old Style', Charter, Georgia, serif;
    		--finder-mono: 'Iowan Old Style', Charter, Georgia, serif;
    		--finder-radius: 6px;
    		--finder-column-width: 13rem;
    		--finder-icon-size: 4rem;
    
    		height: 100%;
    		border-radius: 10px;
    		overflow: hidden;
    	}
    </style>

    Data hooks

    For anything the variables do not reach, the markup carries state as attributes: data-view on the .finder root, data-kind and data-path on every entry, data-selected and, in the columns view, data-open. The Finder's styles are scoped to it, so target these from a global stylesheet, or wrap them in :global(...) inside a component. To style entries from your own data, entryclass and entrystyle return a class or an inline style per entry.

    css
    /* app.css */
    
    /* The root carries the view: 'icons' | 'list' | 'columns'. */
    .finder[data-view='list'] {
      font-size: 13px;
    }
    
    /* Every row, tile and column item says what it is and where it lives. */
    .finder [data-kind='directory'] {
      font-weight: 500;
    }
    .finder [data-path$='.md'] {
      color: steelblue;
    }
    
    /* 'true' on the selected entry; in columns also on each folder you came through. */
    .finder [data-selected='true'] {
      outline: 1px solid currentColor;
    }
    .finder [data-open='true'] {
      font-style: italic;
    }

    API

    One component, three adapters, a change feed for backends that cannot report changes, and the types that connect them. Everything is exported from svelte-finder.

    Finder

    Fills its container: give the parent, or the Finder through class, a height.

    NameTypeDoes
    fsFinderFS<E>The filesystem to browse. Required. The Finder is generic over its entry type E, so every callback and snippet below receives your own entry type.
    rootstring (default '/')Where the path bar starts. Navigation and search never go above it, and a path outside it falls back to it.
    path (bindable)stringThe open directory. Defaults to root. Opening a folder writes it back; assigning it navigates.
    view (bindable)'icons' | 'list' | 'columns'How the directory is laid out. Defaults to columns. The toolbar switch writes it back.
    selected (bindable)string | nullPath of the selected entry, or null. Highlighted while its folder is open or it matches the search.
    query (bindable)string (default '')The search text. Anything but whitespace shows the matches under root instead of the folder.
    searchableboolean (default true)Shows the search field. Without fs.search it matches path substrings across the whole tree under root.
    rootLabelstringLabel of the root crumb in the path bar. Defaults to the last segment of root.
    onopen(entry: E) => voidA file was double-clicked or opened with Enter (or clicked by a keyboard or screen reader, which have no double-click). A single click only selects; in the columns view it also opens a folder as the next column. Folders open in place instead of calling this.
    href(entry: E) => string | undefinedA URL renders the entry as a link: a click or Enter follows it instead of selecting.
    onentrycontextmenu(entry: E, event: MouseEvent) => voidRight-click on an entry. Call event.preventDefault() to replace the browser menu.
    entryclass(entry: E) => string | undefinedExtra class on each entry: column item, tile or list row.
    entrystyle(entry: E) => string | undefinedInline style on each entry: column item, tile or list row.
    metaSnippet<[E, FinderView]>Trailing content after a name: at the end of a column row or the list name cell, under a tile.
    columns{ label, align?, cell: Snippet<[E]> }[]Extra list-view columns between Name and Size. align is 'start' or 'end'.
    group'modified' | null (default null)List view: puts the open folder's entries under Finder's date headings (Today, Yesterday, Previous 7 Days, Previous 30 Days, this year's months, then years), sorted as usual inside each. Search results stay ungrouped. Needs Temporal: in a browser without it (Safari today) the list stays flat.
    previewSnippet<[E | null, folder: string]>Fills the columns view pane, which is always shown when this is given: the selected file, or null and the open folder. Without it the pane shows the selected file's facts, and with fs.read an image (png, jpg, gif, webp, avif, svg) or the start of a text file.
    iconSnippet<[E, 'small' | 'large', builtin: Snippet]>Replaces the icon of every entry: small in the list and columns views, large in icons. Render builtin() for the entries you leave alone.
    toolbarboolean (default true)Shows the toolbar: Back and Forward, view switch, path bar, Go to folder, search field and actions.
    actionsSnippetRendered at the end of the toolbar.
    statusbarboolean (default true)Shows the folder and file count, or the match count while searching.
    statusSnippet<[{ folders, files, matches }]>Replaces the status bar text. matches is null unless a search is running.
    classstringAdded to the root element.
    refresh()() => voidLists the visible directories again and restarts a running search. Reach it with bind:this.
    back(), forward()() => voidOpen the previous or next folder in the history, as the toolbar's Back and Forward buttons do. Through bind:this.
    go(index)(index: number) => voidOpen the folder at history.paths[index]. Through bind:this.
    history{ readonly paths: string[]; readonly index: number }The folders the Finder has shown, oldest first, and the index of the open one; reactive. Opening a folder after going back drops the forward part, as in a browser. Through bind:this.
    goToFolder()() => voidShows Go to folder, as the toolbar button and Cmd/Ctrl+Shift+G (while the Finder has focus) do: type a path, Tab completes, Enter goes there, Escape closes.

    Adapters

    Each returns a FinderFS. None imports its backend: they type the slice of it they call, so the backend is your dependency, not this package’s.

    NameTypeDoes
    memoryFS(tree)(tree: MemoryTree, options?: { modified?: (path: string) => number }) => MemoryFSAn in-memory tree: a string or Uint8Array is a file, an object a directory. Implements watch, read and move, and adds write(path, content), mkdir(path) and remove(path).
    modernMonacoFS(fs)(fs: Workspace["fs"]) => FinderFSThe filesystem of a modern-monaco Workspace, as used by modern-monaco-svelte. Implements watch, so editor saves show up, and read and move through readFile and rename.
    justBashFS(fs)(fs: Bash["fs"]) => FinderFSThe filesystem of a just-bash shell. Implements read and move (readFileBuffer, mv; an existing target is refused). No watch of its own: wrap it in withChangeFeed.
    withChangeFeed(fs)(fs: FinderFS<E>) => FinderFS<E> & { notify() }Adds watch to a backend without change events: call notify() after you change something and every Finder on it lists again. Events of an existing watch still arrive; a move through it notifies by itself.

    Types and helpers

    NameTypeDoes
    FinderEntry{ name, path, kind, size?, modified?, symlink? }An entry: base name, absolute path, 'file' or 'directory', size in bytes and modification time in epoch milliseconds when the backend knows them. symlink marks a link; kind is its target's.
    FinderFS<E>{ list, watch?, search?, read?, move? }list(path) resolves the entries of a directory and rejects for anything else. watch(path, onChange) returns an unsubscribe. search(query, root, { signal }) replaces the tree walk with your own search and is aborted when the query changes. read(path) resolves a file's bytes, for the preview of images and text. move(from, to) moves an entry to its new full path and rejects if that exists; with it entries drag onto folders and path levels.
    FinderView'icons' | 'list' | 'columns'Type of the view prop.
    joinPath(dir, name)string'/a' and 'b' give '/a/b'; the root never doubles its slash.
    parentPath(path)string'/a/b' gives '/a', '/a' and '/' give '/'.