diff --git a/web/src/components/ui/select.stories.tsx b/web/src/components/ui/select.stories.tsx
new file mode 100644
index 0000000..c58dfe6
--- /dev/null
+++ b/web/src/components/ui/select.stories.tsx
@@ -0,0 +1,38 @@
+import type { Meta, StoryObj } from '@storybook/react-vite'
+import { useState } from 'react'
+import { expect, within } from 'storybook/test'
+
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select'
+
+function Controlled() {
+ const [value, setValue] = useState('')
+ return (
+
+ )
+}
+
+const meta = {
+ component: Select,
+ tags: ['ai-generated'],
+ render: () => ,
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ play: async ({ canvas, userEvent }) => {
+ await userEvent.click(canvas.getByRole('combobox', { name: 'Fruit' }))
+ await userEvent.click(await within(document.body).findByRole('option', { name: 'Pear' }))
+ await expect(canvas.getByRole('combobox', { name: 'Fruit' })).toHaveTextContent('Pear')
+ },
+}
diff --git a/web/src/components/ui/select.tsx b/web/src/components/ui/select.tsx
new file mode 100644
index 0000000..6d5ccfb
--- /dev/null
+++ b/web/src/components/ui/select.tsx
@@ -0,0 +1,132 @@
+import * as React from "react"
+import { Select as SelectPrimitive } from "@base-ui/react/select"
+import { Check, ChevronDown } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+// Base UI's resolves the selected item's *label* from the
+// Root `items` prop only; without it the trigger shows the raw value. We
+// derive that map from the rendered children so consumers keep
+// the simple `Label` API and still get
+// labels (not raw values) in the trigger.
+function collectItems(
+ node: React.ReactNode,
+ out: Array<{ value: unknown; label: React.ReactNode }>
+) {
+ React.Children.forEach(node, (child) => {
+ if (!React.isValidElement(child)) {
+ return
+ }
+
+ if (child.type === SelectItem) {
+ const props = child.props as SelectPrimitive.Item.Props
+
+ out.push({ value: props.value, label: props.children })
+
+ return
+ }
+
+ const props = child.props as { children?: React.ReactNode }
+
+ if (props.children != null) {
+ collectItems(props.children, out)
+ }
+ })
+}
+
+function Select({ children, ...props }: SelectPrimitive.Root.Props) {
+ const items = React.useMemo(() => {
+ const collected: Array<{ value: unknown; label: React.ReactNode }> = []
+
+ collectItems(children, collected)
+
+ return collected
+ }, [children])
+
+ return (
+
+ {children}
+
+ )
+}
+
+function SelectTrigger({ className, children, ...props }: SelectPrimitive.Trigger.Props) {
+ return (
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
+ return (
+
+ )
+}
+
+function SelectContent({
+ className,
+ sideOffset = 6,
+ ...props
+}: SelectPrimitive.Popup.Props & {
+ sideOffset?: SelectPrimitive.Positioner.Props["sideOffset"]
+}) {
+ return (
+
+
+
+
+
+ )
+}
+
+function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) {
+ return (
+
+ {children}
+
+
+
+
+ )
+}
+
+export { Select, SelectTrigger, SelectValue, SelectContent, SelectItem }