diff --git a/.changeset/late-shrimps-sniff.md b/.changeset/late-shrimps-sniff.md
new file mode 100644
index 000000000..171976637
--- /dev/null
+++ b/.changeset/late-shrimps-sniff.md
@@ -0,0 +1,5 @@
+---
+'svelte-ux': patch
+---
+
+Unify and enhance search functionality of SelectField and MultiSelect components, enabling new custom search capability in MultiSelect.
diff --git a/packages/svelte-ux/src/lib/components/MultiSelect.svelte b/packages/svelte-ux/src/lib/components/MultiSelect.svelte
index edbe14b28..0afabe15c 100644
--- a/packages/svelte-ux/src/lib/components/MultiSelect.svelte
+++ b/packages/svelte-ux/src/lib/components/MultiSelect.svelte
@@ -8,6 +8,7 @@
 
   import { dirtyStore, selectionStore, uniqueStore, changeStore } from '@layerstack/svelte-stores';
   import { cls } from '@layerstack/tailwind';
+  import { Logger } from '@layerstack/utils';
 
   import Button from './Button.svelte';
   import InfiniteScroll from './InfiniteScroll.svelte';
@@ -17,11 +18,12 @@
   import type { MenuOption } from '../types/index.js';
   import { getComponentClasses } from './theme.js';
 
+  const logger = new Logger('MultiSelect');
+
   export let options: MenuOption<TValue>[];
   export let value: TValue[] = [];
   export let indeterminateSelected: typeof value = [];
   export let duration = 200;
-  export let inlineSearch = false;
   export let autoFocusSearch = false;
   export let placeholder = 'Search items';
   export let optionProps: Partial<ComponentProps<MultiSelectOption>> | undefined = undefined;
@@ -88,17 +90,39 @@
   $: [selectedOptions, unselectedOptions] = partition(options, (o) => value.includes(o.value));
 
   // Filter by search text
-  function applyFilter(option: MenuOption<TValue>, searchText: string) {
-    if (searchText) {
-      return option.label.toLowerCase().includes(searchText.toLowerCase());
+  let defaultSearch = async (text: string, options: MenuOption<TValue>[]) => {
+    logger.debug('search', { text, open });
+
+    if (text === '' || options.length === 0) {
+      // Reset options
+      return options;
     } else {
-      // show all if no search set
-      return true;
+      const words = text?.toLowerCase().split(' ') ?? [];
+      return options.filter((option) => {
+        const label = option.label.toLowerCase();
+        return words.every((word) => label.includes(word));
+      });
+    }
+  };
+  let customSearch: typeof defaultSearch | boolean = false;
+  export { customSearch as search };
+  $: search = typeof customSearch === 'boolean' ? defaultSearch : customSearch;
+  $: usingSearch = customSearch !== false;
+
+  let filteredOptions: MenuOption<TValue>[] = [...(options ?? [])];
+  let filteredSelectedOptions: MenuOption<TValue>[] = [...(selectedOptions ?? [])];
+  let filteredUnselectedOptions: MenuOption<TValue>[] = [...(unselectedOptions ?? [])];
+  async function updateFilteredOptions() {
+    if (usingSearch) {
+      [filteredOptions, filteredSelectedOptions, filteredUnselectedOptions] = await Promise.all([
+        search(searchText, options ?? []),
+        search(searchText, selectedOptions ?? []),
+        search(searchText, unselectedOptions ?? []),
+      ]);
     }
   }
-  $: filteredOptions = options.filter((x) => applyFilter(x, searchText));
-  $: filteredSelectedOptions = selectedOptions.filter((x) => applyFilter(x, searchText));
-  $: filteredUnselectedOptions = unselectedOptions.filter((x) => applyFilter(x, searchText));
+  // Re-filter options when `searchText` changes
+  $: searchText, updateFilteredOptions();
 
   const selection = selectionStore({ max });
   // Only "subscribe" to value changes (not `$selection`) to fix correct `value` / topological ordering.  Should be simplified with Svelte 5
@@ -135,7 +159,7 @@
   }
 </script>
 
-{#if inlineSearch}
+{#if usingSearch}
   <div
     class={cls(
       'search',
diff --git a/packages/svelte-ux/src/lib/components/MultiSelectMenu.svelte b/packages/svelte-ux/src/lib/components/MultiSelectMenu.svelte
index ffdffca54..898030f78 100644
--- a/packages/svelte-ux/src/lib/components/MultiSelectMenu.svelte
+++ b/packages/svelte-ux/src/lib/components/MultiSelectMenu.svelte
@@ -20,8 +20,8 @@
   export let duration = 200;
   export let placement: Placement = 'bottom-start';
   export let autoPlacement = true;
-  export let inlineSearch = false;
-  export let autoFocusSearch = inlineSearch;
+  export let search: MultiSelectProps['search'] = false;
+  export let autoFocusSearch = Boolean(search);
   export let placeholder: string | undefined = undefined;
   export let infiniteScroll = false;
   export let searchText = '';
@@ -65,7 +65,7 @@
     {max}
     {open}
     {duration}
-    {inlineSearch}
+    {search}
     {autoFocusSearch}
     {placeholder}
     {infiniteScroll}
diff --git a/packages/svelte-ux/src/lib/components/SelectField.svelte b/packages/svelte-ux/src/lib/components/SelectField.svelte
index 7b57f9088..860ad9ff4 100644
--- a/packages/svelte-ux/src/lib/components/SelectField.svelte
+++ b/packages/svelte-ux/src/lib/components/SelectField.svelte
@@ -144,16 +144,18 @@
   // Reactively call anytime `selected`, `value`, or `options` change
   $: updateSelected(selected, value, options);
 
-  export let search = async (text: string) => {
+  // Filter by search text
+  export let search = async (text: string, options: MenuOption<TValue>[]) => {
     logger.debug('search', { text, open });
 
-    if (text === '') {
+    if (text === '' || options.length === 0) {
       // Reset options
-      filteredOptions = options;
+      return options;
     } else {
       const words = text?.toLowerCase().split(' ') ?? [];
-      filteredOptions = options.filter((option) => {
-        return words.every((word) => option.label.toLowerCase().includes(word));
+      return options.filter((option) => {
+        const label = option.label.toLowerCase();
+        return words.every((word) => label.includes(word));
       });
     }
   };
@@ -198,32 +200,40 @@
     }
   }
 
+  let previousSearchText = '';
+  // Do not search if menu is not open / closing on selection
   $: if (open) {
     // Capture current highlighted item (attempt to restore after searching)
     const prevHighlightedOption = filteredOptions[highlightIndex];
 
     // Do not search if menu is not open / closing on selection
-    search(searchText).then(() => {
-      // TODO: Find a way for scrollIntoView to still highlight after the menu height transition finishes
-      const selectedIndex = filteredOptions.findIndex((o) => o.value === value);
-      if (highlightIndex === -1) {
-        // Highlight selected if none currently
-        highlightIndex = selectedIndex === -1 ? nextOptionIndex(-1) : selectedIndex;
-      } else {
-        // Attempt to re-highlight previously highlighted option after search
-        const prevHighlightedOptionIndex = filteredOptions.findIndex(
-          (o) => o === prevHighlightedOption
-        );
-
-        if (prevHighlightedOptionIndex !== -1) {
-          // Maintain previously highlight index after filter update (option still available)
-          highlightIndex = prevHighlightedOptionIndex;
+    if (searchText.trim() && previousSearchText !== searchText) {
+      previousSearchText = searchText;
+      search(searchText, options ?? []).then((options) => {
+        // Update filtered options with new results
+        filteredOptions = options;
+        // TODO: Find a way for scrollIntoView to still highlight after the menu height transition finishes
+        const selectedIndex = options.findIndex((o) => o.value === value);
+        if (highlightIndex === -1) {
+          // Highlight selected if none currently
+          highlightIndex = selectedIndex === -1 ? nextOptionIndex(-1) : selectedIndex;
         } else {
-          // Highlight first option
-          highlightIndex = nextOptionIndex(-1);
+          // Attempt to re-highlight previously highlighted option after search
+          const prevHighlightedOptionIndex = options.findIndex((o) => o === prevHighlightedOption);
+
+          if (prevHighlightedOptionIndex !== -1) {
+            // Maintain previously highlight index after filter update (option still available)
+            highlightIndex = prevHighlightedOptionIndex;
+          } else {
+            // Highlight first option
+            highlightIndex = nextOptionIndex(-1);
+          }
         }
-      }
-    });
+      });
+    } else if (searchText.trim() === '') {
+      // Restore options if cleared (show all options)
+      filteredOptions = options;
+    }
   }
 
   function onChange(e: ComponentEvents<TextField>['change']) {
diff --git a/packages/svelte-ux/src/routes/docs/components/MultiSelect/+page.svelte b/packages/svelte-ux/src/routes/docs/components/MultiSelect/+page.svelte
index c331b71c6..89bfed545 100644
--- a/packages/svelte-ux/src/routes/docs/components/MultiSelect/+page.svelte
+++ b/packages/svelte-ux/src/routes/docs/components/MultiSelect/+page.svelte
@@ -60,11 +60,11 @@
   />
 </Preview>
 
-<h2>inlineSearch</h2>
+<h2>search</h2>
 
 <Preview>
   {value.length} selected
-  <MultiSelect {options} {value} on:change={(e) => (value = e.detail.value)} inlineSearch />
+  <MultiSelect {options} {value} on:change={(e) => (value = e.detail.value)} search />
 </Preview>
 
 <h2>maintainOrder</h2>
@@ -133,17 +133,12 @@
   </div>
 </Preview>
 
-<h2>many options w/ inlineSearch</h2>
+<h2>many options w/ search</h2>
 
 <Preview>
   {value.length} selected
   <div class="flex flex-col max-h-[360px] overflow-auto">
-    <MultiSelect
-      options={manyOptions}
-      {value}
-      on:change={(e) => (value = e.detail.value)}
-      inlineSearch
-    />
+    <MultiSelect options={manyOptions} {value} on:change={(e) => (value = e.detail.value)} search />
   </div>
 </Preview>
 
@@ -156,7 +151,7 @@
       options={manyOptions}
       {value}
       on:change={(e) => (value = e.detail.value)}
-      inlineSearch
+      search
       infiniteScroll
     />
   </div>
@@ -167,7 +162,7 @@
 <Preview>
   {value.length} selected
   <div class="flex flex-col max-h-[360px] overflow-auto">
-    <MultiSelect {options} {value} on:change={(e) => (value = e.detail.value)} inlineSearch>
+    <MultiSelect {options} {value} on:change={(e) => (value = e.detail.value)} search>
       <div slot="actions">
         <Button color="primary" icon={mdiPlus}>Add item</Button>
       </div>
@@ -180,7 +175,7 @@
 <Preview>
   {value.length} selected
   <div class="flex flex-col max-h-[360px] overflow-auto">
-    <MultiSelect {options} {value} on:change={(e) => (value = e.detail.value)} inlineSearch max={2}>
+    <MultiSelect {options} {value} on:change={(e) => (value = e.detail.value)} search max={2}>
       <div slot="actions" let:selection class="flex items-center">
         {#if selection.isMaxSelected()}
           <div class="text-sm text-danger">Maximum selection reached</div>
@@ -195,7 +190,7 @@
 <Preview>
   {value.length} selected
   <div class="flex flex-col max-h-[360px] overflow-auto">
-    <MultiSelect {options} {value} on:change={(e) => (value = e.detail.value)} inlineSearch>
+    <MultiSelect {options} {value} on:change={(e) => (value = e.detail.value)} search>
       <MultiSelectOption
         slot="option"
         let:option
@@ -264,6 +259,56 @@
   </div>
 </Preview>
 
+<h2>option slot with custom search</h2>
+
+<Preview>
+  <div class="mb-4 text-surface-content text-sm">
+    Options can be searched by their values ({options.map((o) => o.value).join(', ')})
+  </div>
+  {value.length} selected
+  <MultiSelect
+    {options}
+    {value}
+    on:change={(e) => (value = e.detail.value)}
+    search={async (text, options) => {
+      text = text?.trim();
+      if (!text || options.length === 0) {
+        return options;
+      } else {
+        const words = text?.toLowerCase().split(' ') ?? [];
+        return options.filter((option) => {
+          const searchableText = [option.label, option.value].join(' ').toLowerCase();
+          return words.every((word) => searchableText.includes(word));
+        });
+      }
+    }}
+  >
+    <MultiSelectOption
+      slot="option"
+      let:label
+      let:value
+      let:checked
+      let:indeterminate
+      let:disabled
+      let:onChange
+      {checked}
+      {indeterminate}
+      {disabled}
+      on:change={onChange}
+      classes={{
+        container: 'flex items-center gap-1',
+      }}
+    >
+      <span
+        class="grid place-items-center size-6 text-xs rounded-full bg-surface-content/15 text-surface-content/75"
+      >
+        {value}
+      </span>
+      {label}
+    </MultiSelectOption>
+  </MultiSelect>
+</Preview>
+
 <h2>Form integration</h2>
 
 <Preview>
diff --git a/packages/svelte-ux/src/routes/docs/components/MultiSelectMenu/+page.svelte b/packages/svelte-ux/src/routes/docs/components/MultiSelectMenu/+page.svelte
index bbdae0b33..2c2263148 100644
--- a/packages/svelte-ux/src/routes/docs/components/MultiSelectMenu/+page.svelte
+++ b/packages/svelte-ux/src/routes/docs/components/MultiSelectMenu/+page.svelte
@@ -92,7 +92,7 @@
   </div>
 </Preview>
 
-<h2>inlineSearch</h2>
+<h2>search</h2>
 
 <Preview>
   <span>
@@ -107,7 +107,7 @@
         }}
         {open}
         on:close={toggleOff}
-        inlineSearch
+        search
         placeholder="Pick a number"
       />
     </ToggleButton>
@@ -136,7 +136,7 @@
   </span>
 </Preview>
 
-<h2>maintainOrder w/ inlineSearch</h2>
+<h2>maintainOrder w/ search</h2>
 
 <Preview>
   <span>
@@ -151,7 +151,7 @@
         }}
         {open}
         on:close={toggleOff}
-        inlineSearch
+        search
         maintainOrder
         placeholder="Pick a number"
       />
@@ -180,7 +180,7 @@
   </span>
 </Preview>
 
-<h2>many options w/ inlineSearch</h2>
+<h2>many options w/ search</h2>
 
 <Preview>
   <span>
@@ -196,7 +196,7 @@
         {open}
         on:close={toggleOff}
         classes={{ menu: 'max-h-[360px] w-[360px]' }}
-        inlineSearch
+        search
       />
     </ToggleButton>
   </span>
@@ -218,7 +218,7 @@
         {open}
         on:close={toggleOff}
         classes={{ menu: 'max-h-[360px] w-[360px]' }}
-        inlineSearch
+        search
         infiniteScroll
       />
     </ToggleButton>
@@ -287,7 +287,7 @@
         {open}
         on:close={toggleOff}
         classes={{ menu: 'w-[360px]' }}
-        inlineSearch
+        search
       >
         <div slot="actions">
           <Button color="primary" icon={mdiPlus}>Add item</Button>
@@ -313,7 +313,7 @@
         {open}
         on:close={toggleOff}
         classes={{ menu: 'w-[360px]' }}
-        inlineSearch
+        search
       >
         <MultiSelectOption
           slot="option"
@@ -336,3 +336,63 @@
     </ToggleButton>
   </span>
 </Preview>
+
+<h2>option slot with custom search</h2>
+
+<Preview>
+  <div class="mb-4 text-surface-content text-sm">
+    Options can be searched by their values ({options.map((o) => o.value).join(', ')})
+  </div>
+  <span>
+    <ToggleButton let:on={open} let:toggleOff transition={false}>
+      {value.length} selected
+      <MultiSelectMenu
+        {options}
+        {value}
+        on:change={(e) => {
+          // @ts-expect-error
+          value = e.detail.value;
+        }}
+        {open}
+        on:close={toggleOff}
+        classes={{ menu: 'w-[360px]' }}
+        search={async (text, options) => {
+          text = text?.trim();
+          if (!text || options.length === 0) {
+            return options;
+          } else {
+            const words = text?.toLowerCase().split(' ') ?? [];
+            return options.filter((option) => {
+              const searchableText = [option.label, option.value].join(' ').toLowerCase();
+              return words.every((word) => searchableText.includes(word));
+            });
+          }
+        }}
+      >
+        <MultiSelectOption
+          slot="option"
+          let:option
+          let:label
+          let:value
+          let:checked
+          let:indeterminate
+          let:onChange
+          {checked}
+          {indeterminate}
+          on:change={onChange}
+          classes={{
+            root: 'py-1',
+            container: 'flex items-center gap-1',
+          }}
+        >
+          <span
+            class="grid place-items-center size-6 text-xs rounded-full bg-surface-content/15 text-surface-content/75"
+          >
+            {value}
+          </span>
+          {label}
+        </MultiSelectOption>
+      </MultiSelectMenu>
+    </ToggleButton>
+  </span>
+</Preview>
diff --git a/packages/svelte-ux/src/routes/docs/components/SelectField/+page.svelte b/packages/svelte-ux/src/routes/docs/components/SelectField/+page.svelte
index f1284b771..4cd0d2222 100644
--- a/packages/svelte-ux/src/routes/docs/components/SelectField/+page.svelte
+++ b/packages/svelte-ux/src/routes/docs/components/SelectField/+page.svelte
@@ -8,6 +8,7 @@
     Form,
     MenuItem,
     SelectField,
+    State,
     TextField,
     Toggle,
     type MenuOption,
@@ -324,6 +325,49 @@
   </SelectField>
 </Preview>
 
+<h2>option slot with custom search</h2>
+
+<Preview>
+  <div class="mb-4 text-surface-content text-sm">
+    Options can be searched by their values ({options.map((o) => o.value).join(', ')})
+  </div>
+  <SelectField
+    {options}
+    search={async (text, options) => {
+      text = text?.trim();
+      if (!text || options.length === 0) {
+        return options;
+      } else {
+        const words = text?.toLowerCase().split(' ') ?? [];
+        return options.filter((option) => {
+          const searchableText = [option.label, option.value].join(' ').toLowerCase();
+          return words.every((word) => searchableText.includes(word));
+        });
+      }
+    }}
+  >
+    <MenuItem
+      slot="option"
+      let:option
+      let:index
+      let:selected
+      let:highlightIndex
+      class={cls(
+        index === highlightIndex && 'bg-surface-content/5',
+        option === selected && 'font-semibold',
+        option.group ? 'px-4' : 'px-2'
+      )}
+      scrollIntoView={index === highlightIndex}
+      disabled={option.disabled}
+    >
+      <div>
+        <div>{option.label}</div>
+        <div class="text-sm text-surface-content/50">{option.value}</div>
+      </div>
+    </MenuItem>
+  </SelectField>
+</Preview>
+
 <h2>Prepend slot</h2>
 
 <Preview>
@@ -485,17 +529,23 @@
   </SelectField>
 </Preview>
 
-<h2>Search</h2>
+<h2>Custom search (case sensitive)</h2>
 
 <Preview>
-  <SelectField
-    {options}
-    search={async () => {
-      console.log('search override...');
-      await delay(1000);
-      console.log('search override done');
-    }}
-  />
+  <State initial={false} let:value={loading} let:set={setLoading}>
+    {loading ? 'Loading...' : 'Search'}
+    <SelectField
+      {options}
+      search={async (text, options) => {
+        setLoading(true);
+        console.log('search override...');
+        await delay(1000);
+        console.log('search override done');
+        setLoading(false);
+        return options.filter((option) => option.label.includes(text));
+      }}
+    />
+  </State>
 </Preview>
 
 <h2>Placement</h2>