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>
<SelectItem v-for="item in frameworks" :key="item.value" :item="item">
<SelectItemText>{{ item.label }}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
</SelectContent>
</SelectRoot>
npx @left4code/cosmic-ui-cli@latest add select
This writes the component and everything it imports, then installs the packages it needs. Run npx @left4code/cosmic-ui-cli@latest initfirst if you haven't already (it detects Vue automatically).
Install the following dependencies:
This component is built directly on the Zag.js state machine (not a wrapper), plus the sharedPortalandusePresenceprimitives from theMenupage, and the same dropdown frame used byMenu. Unlike Combobox, the select's anchor is the trigger button itself, so there's no separate control wrapper. Vue SFCs can't export multiple components from one file, so Select is a folder instead of a single file — copy all of it into your project.
import type { InjectionKey, ComputedRef } from "vue";
import type * as select from "@zag-js/select";
type SelectApi = ComputedRef<ReturnType<typeof select.connect>>;
const SelectApiKey: InjectionKey<SelectApi> = Symbol("select-api");
const SelectItemKey: InjectionKey<select.CollectionItem> = Symbol("select-item");
export { SelectApiKey, SelectItemKey };
export type { SelectApi };
<script setup lang="ts">
import { computed, provide, useAttrs, useId } from "vue";
import { useMachine, normalizeProps } from "@zag-js/vue";
import * as select from "@zag-js/select";
import { SelectApiKey } from "./context";
defineOptions({ inheritAttrs: false });
const rawAttrs = useAttrs() as Partial<select.Props>;
const id = useId();
const service = useMachine(
select.machine,
computed(() => ({ id, ...rawAttrs }))
);
const api = computed(() => select.connect(service, normalizeProps));
provide(SelectApiKey, api);
</script>
<template>
<div v-bind="api.getRootProps()">
<slot />
</div>
</template>
<script setup lang="ts">
import { inject } from "vue";
import { twMerge } from "tailwind-merge";
import Button from "@/components/ui/button.vue";
import { ChevronDown } from "@lucide/vue";
import { SelectApiKey } from "./context";
const { class: className, placeholder = "Select option..." } = defineProps<{
class?: string;
placeholder?: string;
}>();
const injectedApi = inject(SelectApiKey);
if (!injectedApi) throw new Error("Select parts must be used within <SelectRoot>");
const api = injectedApi;
</script>
<template>
<Button
v-bind="api.getTriggerProps()"
:class="twMerge(['w-full min-w-55 px-0 [&>span]:justify-start px-8', className])"
>
{{ api.valueAsString || placeholder }}
<span
v-bind="api.getIndicatorProps()"
class="ms-auto transition-transform group-data-[state=open]:rotate-180"
>
<ChevronDown class="size-4" />
</span>
</Button>
</template>
<script setup lang="ts">
import { computed, inject } from "vue";
import { twMerge } from "tailwind-merge";
import Portal from "@/components/ui/portal.vue";
import Frame, { parsePaths } from "@/components/ui/frame.vue";
import { usePresence } from "@/components/ui/presence";
import { SelectApiKey } from "./context";
const { class: className } = defineProps<{ class?: string }>();
const injectedApi = inject(SelectApiKey);
if (!injectedApi) throw new Error("Select parts must be used within <SelectRoot>");
const api = injectedApi;
const presence = usePresence(() => api.value.open);
const positionerStyle = computed(() => ({
...api.value.getPositionerProps().style,
zIndex: 70,
}));
const framePaths = 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"]]}]'
);
</script>
<template>
<Portal>
<div v-bind="api.getPositionerProps()" :style="positionerStyle">
<div
v-bind="api.getContentProps()"
:ref="presence.setNode"
:hidden="!presence.present.value"
:class="
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 class="absolute inset-0 group-data-[placement=top-start]:scale-y-[-1]">
<Frame :paths="framePaths" enable-backdrop-blur />
</div>
<div class="relative flex flex-col gap-2.5">
<slot />
</div>
</div>
</div>
</Portal>
</template>
<script setup lang="ts">
import { inject, provide } from "vue";
import { twMerge } from "tailwind-merge";
import type * as select from "@zag-js/select";
import { SelectApiKey, SelectItemKey } from "./context";
const rawProps = defineProps({
class: { type: String, required: false },
item: { type: null, required: true },
});
const item = rawProps.item as select.CollectionItem;
const injectedApi = inject(SelectApiKey);
if (!injectedApi) throw new Error("Select parts must be used within <SelectRoot>");
const api = injectedApi;
provide(SelectItemKey, item);
</script>
<template>
<div
v-bind="api.getItemProps({ item })"
:class="
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',
rawProps.class,
])
"
>
<slot />
</div>
</template>
<script setup lang="ts">
import { inject } from "vue";
import { SelectApiKey, SelectItemKey } from "./context";
const injectedApi = inject(SelectApiKey);
if (!injectedApi) throw new Error("Select parts must be used within <SelectRoot>");
const api = injectedApi;
const item = inject(SelectItemKey);
if (!item) throw new Error("SelectItemText must be used within <SelectItem>");
</script>
<template>
<div v-bind="api.getItemTextProps({ item })">
<slot />
</div>
</template>
<script setup lang="ts">
import { inject } from "vue";
import { twMerge } from "tailwind-merge";
import { Check } from "@lucide/vue";
import { SelectApiKey, SelectItemKey } from "./context";
const { class: className } = defineProps<{ class?: string }>();
const injectedApi = inject(SelectApiKey);
if (!injectedApi) throw new Error("Select parts must be used within <SelectRoot>");
const api = injectedApi;
const item = inject(SelectItemKey);
if (!item) throw new Error("SelectItemIndicator must be used within <SelectItem>");
</script>
<template>
<div v-bind="api.getItemIndicatorProps({ item })" :class="twMerge(['ms-auto', className])">
<Check class="size-3.5" />
</div>
</template>
export { default as SelectRoot } from "./SelectRoot.vue";
export { default as SelectTrigger } from "./SelectTrigger.vue";
export { default as SelectContent } from "./SelectContent.vue";
export { default as SelectItem } from "./SelectItem.vue";
export { default as SelectItemText } from "./SelectItemText.vue";
export { default as SelectItemIndicator } from "./SelectItemIndicator.vue";
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>
<SelectItem v-for="item in frameworks" :key="item.value" :item="item">
<SelectItemText>{{ item.label }}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
</SelectContent>
</SelectRoot>