Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
762ac48
docs: add position=item-aligned documentation and demo scaffolding
maxffarrell May 4, 2026
3c060c6
feat(Select): port position=item-aligned from Radix UI
maxffarrell May 4, 2026
60f1ce7
fix(Select): use Svelte action for wrapper ref in item-aligned mode
maxffarrell May 4, 2026
48742c2
fix(Select): item-aligned position and hold-click issues
maxffarrell May 4, 2026
fd5b0e6
fix(Select): prevent scroll reposition feedback loop in item-aligned …
maxffarrell May 4, 2026
944af92
fix(Select): restore mousedown→hover→mouseup item selection in item-a…
maxffarrell May 4, 2026
9acd831
fix(Select): set item-aligned portal width to match trigger
maxffarrell May 4, 2026
310c6f4
fix(Select): item-aligned content scrolls out of view with trigger on…
maxffarrell May 4, 2026
3ac8126
fix(Select): use Radix-style 10px pointer-movement threshold to guard…
maxffarrell May 4, 2026
9f411dd
fix(Select): item-aligned positioning, scroll button reactivity, and …
maxffarrell May 4, 2026
25da33c
fix(Select): align item text with trigger text in demo; fix scroll re…
maxffarrell May 4, 2026
0fd1101
fix h alignment of portal item
maxffarrell May 4, 2026
35524fc
fix(Select): pixel-accurate item-aligned positioning for all items an…
maxffarrell May 4, 2026
2acd445
fix(select): fix TypeScript check errors in item-aligned browser tests
maxffarrell May 4, 2026
f9bca16
Merge remote-tracking branch 'upstream/main' into select-align-item
maxffarrell May 9, 2026
634f860
fix(select): keep item-aligned positioning when highlighting; suppres…
maxffarrell May 9, 2026
80bf87e
test(select): wait for dismissable layer before returning from open h…
maxffarrell May 9, 2026
5af73fb
Merge remote-tracking branch 'upstream/main' into select-align-item
maxffarrell Jun 7, 2026
faa9873
fix(select): improve item-aligned positioning
maxffarrell Jun 7, 2026
4cd0930
test(popover): stabilize outside click assertion
maxffarrell Jun 7, 2026
6ced94d
fix select item-aligned page scroll
maxffarrell Jun 20, 2026
49ec078
chore: add select item-aligned changeset
maxffarrell Jun 21, 2026
e80a153
fix(select): keep item-aligned viewport stable on scroll
maxffarrell Jun 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/select-item-aligned.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"bits-ui": minor
---

feat(Select): add item-aligned positioning
28 changes: 27 additions & 1 deletion docs/content/components/select.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ description: Enables users to pick from a list of options displayed in a dropdow
---

<script>
import { APISection, ComponentPreview, SelectDemo, SelectDemoCustomAnchor, SelectDemoMultiple, SelectDemoTransition, SelectDemoAutoScrollDelay, Callout, SelectDemoCustomValue } from '$lib/components'
import { APISection, ComponentPreview, SelectDemo, SelectDemoCustomAnchor, SelectDemoMultiple, SelectDemoTransition, SelectDemoAutoScrollDelay, Callout, SelectDemoCustomValue, SelectDemoItemAligned } from '$lib/components'
let { schemas } = $props()
</script>

Expand Down Expand Up @@ -290,6 +290,32 @@ You can opt-out of this behavior by instead using the `Select.ContentStatic` com

When using this component, you'll need to handle the positioning of the content yourself. Keep in mind that using `Select.Portal` alongside `Select.ContentStatic` may result in some unexpected positioning behavior, feel free to not use the portal or work around it.

## Item-Aligned Positioning

By default, `Select.Content` uses [Floating UI](https://floating-ui.com/) to anchor the list to the trigger, much like a popover. When you set `position="item-aligned"`, the content is instead placed so that the currently selected item sits directly over the trigger, mirroring the behavior of a native `<select>` on macOS.

```svelte /position="item-aligned"/
<Select.Content position="item-aligned">
<Select.ScrollUpButton />
<Select.Viewport>
<Select.Item />
</Select.Viewport>
<Select.ScrollDownButton />
</Select.Content>
```

In this mode, the `side`, `sideOffset`, `align`, `alignOffset`, `avoidCollisions`, and other collision-related props are ignored. The `data-side` attribute on the content is set to `none` so that side-specific animations or styles don't run — keep this in mind if your styles depend on `data-[side=...]` selectors.

If the list is taller than the viewport, `Select.Viewport`'s scroll position is adjusted automatically to keep the selected item aligned with the trigger, and the remaining items can be scrolled into view using `Select.ScrollUpButton` and `Select.ScrollDownButton`. For this reason, the `Select.Viewport` and scroll button components should always be included in your structure when using this mode.

<ComponentPreview name="select-demo-item-aligned" componentName="Select">

{#snippet preview()}
<SelectDemoItemAligned />
{/snippet}

</ComponentPreview>

## Custom Anchor

By default, the `Select.Content` is anchored to the `Select.Trigger` component, which determines where the content is positioned.
Expand Down
1 change: 1 addition & 0 deletions docs/src/lib/components/demos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export { default as SelectDemoAutoScrollDelay } from "./select-demo-auto-scroll-
export { default as SelectDemoCustom } from "./select-demo-custom.svelte";
export { default as SelectDemoCustomAnchor } from "./select-demo-custom-anchor.svelte";
export { default as SelectDemoCustomValue } from "./select-demo-custom-value.svelte";
export { default as SelectDemoItemAligned } from "./select-demo-item-aligned.svelte";
export { default as SelectDemoMultiple } from "./select-demo-multiple.svelte";
export { default as SelectDemoTransition } from "./select-demo-transition.svelte";
export { default as MenubarDemo } from "./menubar-demo.svelte";
Expand Down
77 changes: 77 additions & 0 deletions docs/src/lib/components/demos/select-demo-item-aligned.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<script lang="ts">
import { Select } from "bits-ui";
import Check from "phosphor-svelte/lib/Check";
import Palette from "phosphor-svelte/lib/Palette";
import CaretUpDown from "phosphor-svelte/lib/CaretUpDown";
import CaretDoubleUp from "phosphor-svelte/lib/CaretDoubleUp";
import CaretDoubleDown from "phosphor-svelte/lib/CaretDoubleDown";

const themes = [
{ value: "light-monochrome", label: "Light Monochrome" },
{ value: "dark-green", label: "Dark Green" },
{ value: "svelte-orange", label: "Svelte Orange" },
{ value: "punk-pink", label: "Punk Pink" },
{ value: "ocean-blue", label: "Ocean Blue", disabled: true },
{ value: "sunset-orange", label: "Sunset Orange" },
{ value: "sunset-red", label: "Sunset Red" },
{ value: "forest-green", label: "Forest Green" },
{ value: "lavender-purple", label: "Lavender Purple", disabled: true },
{ value: "mustard-yellow", label: "Mustard Yellow" },
{ value: "slate-gray", label: "Slate Gray" },
{ value: "neon-green", label: "Neon Green" },
{ value: "coral-reef", label: "Coral Reef" },
{ value: "midnight-blue", label: "Midnight Blue" },
{ value: "crimson-red", label: "Crimson Red" },
{ value: "mint-green", label: "Mint Green" },
{ value: "pastel-pink", label: "Pastel Pink" },
{ value: "golden-yellow", label: "Golden Yellow" },
{ value: "deep-purple", label: "Deep Purple" },
{ value: "turquoise-blue", label: "Turquoise Blue" },
{ value: "burnt-orange", label: "Burnt Orange" },
];

let value = $state("mustard-yellow");
</script>

<Select.Root type="single" items={themes} bind:value allowDeselect={false}>
<Select.Trigger
class="h-input rounded-9px border-border-input bg-background data-placeholder:text-foreground-alt/50 inline-flex w-[296px] touch-none select-none items-center border px-[11px] text-sm transition-colors"
aria-label="Select a theme"
>
<Palette class="text-muted-foreground mr-[9px] size-6" />
<Select.Value placeholder="Select a theme" />
<CaretUpDown class="text-muted-foreground ml-auto size-6" />
</Select.Trigger>
<Select.Portal>
<Select.Content
position="item-aligned"
class="focus-override border-muted bg-background shadow-popover data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 outline-hidden z-50 w-[var(--bits-select-anchor-width)] min-w-[var(--bits-select-anchor-width)] select-none rounded-xl border px-1 py-3"
>
<Select.ScrollUpButton class="flex w-full items-center justify-center">
<CaretDoubleUp class="size-3" />
</Select.ScrollUpButton>
<Select.Viewport class="p-1">
{#each themes as theme, i (i + theme.value)}
<Select.Item
class="rounded-button data-highlighted:bg-muted outline-hidden data-disabled:opacity-50 flex h-10 w-full select-none items-center py-3 pl-[36px] pr-1.5 text-sm capitalize"
value={theme.value}
label={theme.label}
disabled={theme.disabled}
>
{#snippet children({ selected })}
{theme.label}
{#if selected}
<div class="ml-auto">
<Check aria-label="check" />
</div>
{/if}
{/snippet}
</Select.Item>
{/each}
</Select.Viewport>
<Select.ScrollDownButton class="flex w-full items-center justify-center">
<CaretDoubleDown class="size-3" />
</Select.ScrollDownButton>
</Select.Content>
</Select.Portal>
</Select.Root>
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { default as ItemsProp } from "./items-prop.md"
export { default as DelayProp } from "./delay-prop.md"
export { default as SelectPositionProp } from "./position-prop.md"
export { default as SelectValueChildSnippetProps } from "./select-value-child-snippet-props.md"
export { default as SelectValueChildrenSnippetProps } from "./select-value-children-snippet-props.md"
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```ts
"popper" | "item-aligned"
```
15 changes: 15 additions & 0 deletions docs/src/lib/content/api-reference/select.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { ComboboxScrollAlignmentProp } from "./extended-types/combobox/index.js"
import {
DelayProp,
ItemsProp,
SelectPositionProp,
SelectValueChildSnippetProps,
SelectValueChildrenSnippetProps,
} from "./extended-types/select/index.js";
Expand Down Expand Up @@ -151,6 +152,13 @@ export const content = defineComponentApiSchema<SelectContentPropsWithoutHTML>({
description:
"Whether or not the select should loop through items when reaching the end.",
}),
position: defineEnumProp({
options: ["popper", "item-aligned"],
default: "popper",
description:
"Controls how the content is positioned. `popper` uses Floating UI to anchor the content to the trigger, supporting `side`, `align`, `sideOffset`, and the collision props. `item-aligned` aligns the currently selected item's center with the trigger's center, like a native `<select>` on macOS — the `side`, `sideOffset`, `align`, `alignOffset`, and collision props are ignored, and `data-side` is set to `none`.",
definition: SelectPositionProp,
}),
forceMount: forceMountProp,
preventScroll: definePropSchema({
...preventScrollProp,
Expand Down Expand Up @@ -190,6 +198,13 @@ export const contentStatic = defineComponentApiSchema<SelectContentStaticPropsWi
description:
"Whether or not the select should loop through items when reaching the end.",
}),
position: defineEnumProp({
options: ["popper", "item-aligned"],
default: "popper",
description:
"Controls how the content is positioned. `item-aligned` aligns the selected item's center with the trigger's center, like a native `<select>` on macOS. When this is active, `data-side` is set to `none`.",
definition: SelectPositionProp,
}),
forceMount: forceMountProp,
...withChildProps({
elType: "HTMLDivElement",
Expand Down
23 changes: 23 additions & 0 deletions docs/src/routes/select-test/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<script lang="ts">
import SelectDemoItemAligned from "$lib/components/demos/select-demo-item-aligned.svelte";
</script>

<div style="min-height: 300vh; padding: 20px; display: flex; flex-direction: column; gap: 0;">
<!-- Trigger near top -->
<div style="padding-top: 40px;">
<p style="margin-bottom: 8px; font-size: 12px; color: #888;">Trigger near top of viewport</p>
<SelectDemoItemAligned />
</div>

<!-- Trigger in middle -->
<div style="margin-top: 45vh;">
<p style="margin-bottom: 8px; font-size: 12px; color: #888;">Trigger near middle of viewport</p>
<SelectDemoItemAligned />
</div>

<!-- Trigger near bottom -->
<div style="margin-top: 45vh;">
<p style="margin-bottom: 8px; font-size: 12px; color: #888;">Trigger near bottom of viewport</p>
<SelectDemoItemAligned />
</div>
</div>
18 changes: 14 additions & 4 deletions packages/bits-ui/src/lib/bits/combobox/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import type { BitsPrimitiveInputAttributes } from "$lib/shared/attributes.js";
import type {
SelectBaseRootPropsWithoutHTML,
SelectContentProps,
SelectContentPropsWithoutHTML,
SelectContentStaticProps,
SelectContentStaticPropsWithoutHTML,
SelectMultipleRootPropsWithoutHTML,
SelectSingleRootPropsWithoutHTML,
} from "$lib/bits/select/types.js";
Expand Down Expand Up @@ -34,11 +38,17 @@ export type ComboboxRootPropsWithoutHTML = ComboboxBaseRootPropsWithoutHTML &

export type ComboboxRootProps = ComboboxRootPropsWithoutHTML;

// Combobox doesn't support `position="item-aligned"` — item-aligned positioning
// doesn't apply when the list is filtered by typing.
export type ComboboxContentProps = Omit<SelectContentProps, "position">;
export type ComboboxContentPropsWithoutHTML = Omit<SelectContentPropsWithoutHTML, "position">;
export type ComboboxContentStaticProps = Omit<SelectContentStaticProps, "position">;
export type ComboboxContentStaticPropsWithoutHTML = Omit<
SelectContentStaticPropsWithoutHTML,
"position"
>;

export type {
SelectContentProps as ComboboxContentProps,
SelectContentPropsWithoutHTML as ComboboxContentPropsWithoutHTML,
SelectContentStaticProps as ComboboxContentStaticProps,
SelectContentStaticPropsWithoutHTML as ComboboxContentStaticPropsWithoutHTML,
SelectItemProps as ComboboxItemProps,
SelectItemPropsWithoutHTML as ComboboxItemPropsWithoutHTML,
SelectItemSnippetProps as ComboboxItemSnippetProps,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { createId } from "$lib/internal/create-id.js";
import { noop } from "$lib/internal/noop.js";
import PopperLayerForceMount from "$lib/bits/utilities/popper-layer/popper-layer-force-mount.svelte";
import SelectItemAlignedContent from "./select-item-aligned-content.svelte";

const uid = $props.id();

Expand All @@ -19,6 +20,7 @@
child,
preventScroll = false,
style,
position = "popper",
...restProps
}: SelectContentStaticProps = $props();

Expand All @@ -30,12 +32,45 @@
),
onInteractOutside: boxWith(() => onInteractOutside),
onEscapeKeydown: boxWith(() => onEscapeKeydown),
position: boxWith(() => position),
});

const mergedProps = $derived(mergeProps(restProps, contentState.props));

function mountWrapper(node: HTMLElement) {
contentState.setContentWrapper(node);
return { destroy: () => contentState.setContentWrapper(null) };
}
</script>

{#if forceMount}
{#if contentState.useItemAligned}
<SelectItemAlignedContent
{id}
ref={contentState.opts.ref}
enabled={contentState.root.opts.open.current}
shouldRender={contentState.shouldRender}
{preventScroll}
onEscapeKeydown={contentState.onEscapeKeydown}
onInteractOutside={contentState.onInteractOutside}
onOpenAutoFocus={contentState.onOpenAutoFocus}
onCloseAutoFocus={contentState.onCloseAutoFocus}
trapFocus={false}
loop={false}
>
{#snippet content({ props: layerProps })}
{@const contentProps = mergeProps(restProps, layerProps, contentState.props, { style })}
<div use:mountWrapper data-select-content-wrapper style="position: fixed; display: flex; flex-direction: column;">
{#if child}
{@render child({ props: contentProps, ...contentState.snippetProps })}
{:else}
<div {...contentProps}>
{@render children?.()}
</div>
{/if}
</div>
{/snippet}
</SelectItemAlignedContent>
{:else if forceMount}
<PopperLayerForceMount
{...mergedProps}
{...contentState.popperProps}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { noop } from "$lib/internal/noop.js";
import PopperLayerForceMount from "$lib/bits/utilities/popper-layer/popper-layer-force-mount.svelte";
import { createId } from "$lib/internal/create-id.js";
import SelectItemAlignedContent from "./select-item-aligned-content.svelte";

const uid = $props.id();

Expand All @@ -20,6 +21,7 @@
child,
preventScroll = false,
style,
position = "popper",
...restProps
}: SelectContentProps = $props();

Expand All @@ -31,12 +33,52 @@
),
onInteractOutside: boxWith(() => onInteractOutside),
onEscapeKeydown: boxWith(() => onEscapeKeydown),
position: boxWith(() => position),
});

const mergedProps = $derived(mergeProps(restProps, contentState.props));

// Svelte action to register the fixed wrapper node. Using use: instead of ref={fn}
// because the callback ref syntax doesn't trigger in this Svelte version.
function mountWrapper(node: HTMLElement) {
contentState.setContentWrapper(node);
return { destroy: () => contentState.setContentWrapper(null) };
}
</script>

{#if forceMount}
{#if contentState.useItemAligned}
<SelectItemAlignedContent
{id}
ref={contentState.opts.ref}
enabled={contentState.root.opts.open.current}
shouldRender={contentState.shouldRender}
{preventScroll}
onEscapeKeydown={contentState.onEscapeKeydown}
onInteractOutside={contentState.onInteractOutside}
onOpenAutoFocus={contentState.onOpenAutoFocus}
onCloseAutoFocus={contentState.onCloseAutoFocus}
trapFocus={false}
loop={false}
>
{#snippet content({ props: layerProps })}
{@const contentProps = mergeProps(restProps, layerProps, contentState.props, { style })}
<!-- Radix-style two-div structure: fixed wrapper + content div that fills it -->
<div use:mountWrapper data-select-content-wrapper style="position: fixed; display: flex; flex-direction: column;">
{#if child}
{@render child({
props: contentProps,
wrapperProps: {},
...contentState.snippetProps,
})}
{:else}
<div {...contentProps}>
{@render children?.()}
</div>
{/if}
</div>
{/snippet}
</SelectItemAlignedContent>
{:else if forceMount}
<PopperLayerForceMount
{...mergedProps}
{...contentState.popperProps}
Expand Down
Loading
Loading