const frameworks = [
{ label: "React", value: "react" },
{ label: "Solid", value: "solid" },
{ label: "Vue", value: "vue" },
{ label: "Svelte", value: "svelte" },
];
const frameworksCollection = select.collection({
items: frameworks,
itemToString: (item) => item.label,
itemToValue: (item) => item.value,
});
<SelectRoot collection={frameworksCollection}>
<SelectTrigger />
<SelectContent>
{frameworks.map((item) => (
<SelectItem key={item.value} item={item}>
<SelectItemText>{item.label}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</SelectRoot>
Install the following dependencies:
This component is built directly on the Zag.js state machine (not a wrapper), plus the shared Portal and usePresence primitives from the Menu page, and the same dropdown frame used by Menu. Unlike Combobox, the select's anchor is the trigger button itself, so there's no separate control wrapper. Copy and paste the following code into your project.
import { createContext, useContext, useId } from "react";
import { twMerge } from "tailwind-merge";
import { useMachine, normalizeProps } from "@zag-js/react";
import * as select from "@zag-js/select";
import { Button } from "@/components/ui/button";
import { Frame, parsePaths } from "@/components/ui/frame";
import { Portal } from "@/components/ui/portal";
import { usePresence } from "@/components/ui/presence";
import { ChevronDown, Check } from "lucide-react";
const SelectContext = createContext<ReturnType<typeof select.connect> | null>(null);
const SelectItemContext = createContext<select.CollectionItem | null>(null);
function useSelectContext() {
const api = useContext(SelectContext);
if (!api) throw new Error("Select parts must be used within <SelectRoot>");
return api;
}
function useSelectItemContext() {
const item = useContext(SelectItemContext);
if (item === null) {
throw new Error("SelectItemText/Indicator must be used within <SelectItem>");
}
return item;
}
function SelectRoot({
children,
...rest
}: React.PropsWithChildren<Partial<select.Props>>) {
const service = useMachine(select.machine, { id: useId(), ...rest });
const api = select.connect(service, normalizeProps);
return (
<SelectContext.Provider value={api}>
<div {...api.getRootProps()}>{children}</div>
</SelectContext.Provider>
);
}
function SelectTrigger({
className,
placeholder = "Select option...",
}: {
className?: string;
placeholder?: string;
}) {
const api = useSelectContext();
return (
<Button
{...api.getTriggerProps()}
className={twMerge(["w-full min-w-55 px-0 [&>span]:justify-start px-8", className])}
>
{api.valueAsString || placeholder}
<span
{...api.getIndicatorProps()}
className="ms-auto transition-transform group-data-[state=open]:rotate-180"
>
<ChevronDown className="size-4" />
</span>
</Button>
);
}
function SelectContent({
children,
className,
}: React.PropsWithChildren<{ className?: string }>) {
const api = useSelectContext();
const { present, ref } = usePresence(api.open);
const positionerProps = api.getPositionerProps();
return (
<Portal>
<div {...positionerProps} style={{ ...positionerProps.style, zIndex: 70 }}>
<div
{...api.getContentProps()}
ref={ref}
hidden={!present}
className={twMerge([
"group relative min-w-(--reference-width) px-6 py-7 outline-none mt-1.5",
"[&[data-state='open']]:animate-in [&[data-state='open']]:zoom-in-80 [&[data-state='open']]:fade-in-0 [&[data-state='open']]:duration-200 [&[data-state='open'][data-placement='bottom-start']]:slide-in-from-top-2 [&[data-state='open'][data-placement='left-start']]:slide-in-from-right-2 [&[data-state='open'][data-placement='right-start']]:slide-in-from-left-2 [&[data-state='open'][data-placement='top-start']]:slide-in-from-bottom-2",
"[&[data-state='closed']]:animate-out [&[data-state='closed']]:zoom-out-80 [&[data-state='closed']]:fade-out-0 [&[data-state='closed']]:duration-200",
"[--color-frame-1-stroke:var(--color-primary)]",
"[--color-frame-1-fill:var(--color-primary)]/20",
"[--color-frame-2-stroke:var(--color-accent)]",
"[--color-frame-2-fill:var(--color-accent)]/40",
"[--color-frame-3-stroke:var(--color-accent)]",
"[--color-frame-3-fill:var(--color-accent)]/40",
"[--color-frame-4-stroke:var(--color-accent)]",
"[--color-frame-4-fill:var(--color-accent)]/40",
className,
])}
>
<div className="absolute inset-0 group-data-[placement=top-start]:scale-y-[-1]">
<Frame
paths={parsePaths(
'[{"show":false,"style":{"strokeWidth":"1","stroke":"var(--color-frame-1-stroke)","fill":"var(--color-frame-1-fill)"},"path":[["M","14","6"],["L","50% - 7","6"],["L","50% - 2","0"],["L","50% + 4","0"],["L","50% + 9","6"],["L","100% - 13","6"],["L","100% + 0","19"],["L","100% + 0","100% - 26"],["L","100% - 13","100% - 12"],["L","50% + 13","100% - 12"],["L","50% - 0","100% + 0"],["L","0% + 14","100% + 0"],["L","0% + 0","100% - 13"],["L","0","0% + 19"],["L","14","6"]]},{"show":true,"style":{"strokeWidth":"1","stroke":"var(--color-frame-2-stroke)","fill":"var(--color-frame-2-fill)"},"path":[["M","50% + 16","100% - 8"],["L","50% + 25","100% - 8"],["L","50% + 18","100% - 2"],["L","50% + 9","100% - 2"],["L","50% + 16","100% - 8"]]},{"show":true,"style":{"strokeWidth":"1","stroke":"var(--color-frame-3-stroke)","fill":"var(--color-frame-3-fill)"},"path":[["M","50% + 30","100% - 8"],["L","50% + 37","100% - 8"],["L","50% + 32","100% - 3"],["L","50% + 25","100% - 3"],["L","50% + 30","100% - 8"]]},{"show":true,"style":{"strokeWidth":"1","stroke":"var(--color-frame-4-stroke)","fill":"var(--color-frame-4-fill)"},"path":[["M","50% + 42","100% - 8"],["L","50% + 48","100% - 8"],["L","50% + 44","100% - 4"],["L","50% + 38","100% - 4"],["L","50% + 42","100% - 8"]]}]'
)}
enableBackdropBlur={true}
/>
</div>
<div className="relative flex flex-col gap-2.5">{children}</div>
</div>
</div>
</Portal>
);
}
function SelectItem({
children,
className,
item,
}: React.PropsWithChildren<{ className?: string; item: select.CollectionItem }>) {
const api = useSelectContext();
return (
<SelectItemContext.Provider value={item}>
<div
{...api.getItemProps({ item })}
className={twMerge([
"cursor-pointer flex items-center -mx-3 -my-0.5 px-3 py-0.5 border border-transparent hover:border-primary/30 hover:bg-primary/10 data-[highlighted]:border-primary/30 data-[highlighted]:bg-primary/10",
className,
])}
>
{children}
</div>
</SelectItemContext.Provider>
);
}
function SelectItemText({ children }: React.PropsWithChildren) {
const api = useSelectContext();
const item = useSelectItemContext();
return <div {...api.getItemTextProps({ item })}>{children}</div>;
}
function SelectItemIndicator({ className }: { className?: string }) {
const api = useSelectContext();
const item = useSelectItemContext();
return (
<div {...api.getItemIndicatorProps({ item })} className={twMerge(["ms-auto", className])}>
<Check className="size-3.5" />
</div>
);
}
export {
SelectRoot,
SelectTrigger,
SelectContent,
SelectItem,
SelectItemText,
SelectItemIndicator,
};
Update the import paths to match your project setup.
import {
SelectRoot,
SelectTrigger,
SelectContent,
SelectItem,
SelectItemText,
SelectItemIndicator,
} from "@/components/ui/select";
const frameworks = [
{ label: "React", value: "react" },
{ label: "Solid", value: "solid" },
{ label: "Vue", value: "vue" },
{ label: "Svelte", value: "svelte" },
];
const frameworksCollection = select.collection({
items: frameworks,
itemToString: (item) => item.label,
itemToValue: (item) => item.value,
});
<SelectRoot collection={frameworksCollection}>
<SelectTrigger />
<SelectContent>
{frameworks.map((item) => (
<SelectItem key={item.value} item={item}>
<SelectItemText>{item.label}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</SelectRoot>