Skip to content
edmi-ui

Native Select

The browser select with an Edmi skin. Use on mobile and for long, simple lists.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
Terminal window
npx shadcn@latest add @edmi-ui/native-select
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/native-select
Terminal window
yarn dlx shadcn@latest add @edmi-ui/native-select
Terminal window
bunx shadcn@latest add @edmi-ui/native-select

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/native-select
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/native-select
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/native-select
Terminal window
bunx shadcn-vue@latest add @edmi-ui/native-select

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/native-select.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/native-select.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/native-select.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/native-select.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

import {
NativeSelect,
NativeSelectOptGroup,
NativeSelectOption,
} from "@/components/ui/native-select";
<div className="flex flex-wrap items-start gap-4">
<NativeSelect defaultValue="devnet" className="w-48">
<NativeSelectOption value="devnet">Solana devnet</NativeSelectOption>
<NativeSelectOption value="testnet">Solana testnet</NativeSelectOption>
<NativeSelectOptGroup label="Production">
<NativeSelectOption value="mainnet">Mainnet</NativeSelectOption>
</NativeSelectOptGroup>
</NativeSelect>
<NativeSelect disabled className="w-48">
<NativeSelectOption>Mainnet</NativeSelectOption>
</NativeSelect>
<NativeSelect aria-invalid size="sm" className="w-48">
<NativeSelectOption value="">Required</NativeSelectOption>
</NativeSelect>
</div>
<script setup lang="ts">
import {
NativeSelect,
NativeSelectOptGroup,
NativeSelectOption,
} from "@/components/ui/native-select";
</script>
<template>
<div class="flex flex-wrap items-start gap-4">
<NativeSelect model-value="devnet" class="w-48">
<NativeSelectOption value="devnet">Solana devnet</NativeSelectOption>
<NativeSelectOption value="testnet">Solana testnet</NativeSelectOption>
<NativeSelectOptGroup label="Production">
<NativeSelectOption value="mainnet">Mainnet</NativeSelectOption>
</NativeSelectOptGroup>
</NativeSelect>
<NativeSelect disabled class="w-48">
<NativeSelectOption>Mainnet</NativeSelectOption>
</NativeSelect>
<NativeSelect aria-invalid="true" size="sm" class="w-48">
<NativeSelectOption value="">Required</NativeSelectOption>
</NativeSelect>
</div>
</template>
<script lang="ts">
import {
NativeSelect,
NativeSelectOptGroup,
NativeSelectOption,
} from "$lib/components/ui/native-select";
</script>
<div class="flex flex-wrap items-start gap-4">
<NativeSelect class="w-48">
<NativeSelectOption value="devnet">Solana devnet</NativeSelectOption>
<NativeSelectOption value="testnet">Solana testnet</NativeSelectOption>
<NativeSelectOptGroup label="Production">
<NativeSelectOption value="mainnet">Mainnet</NativeSelectOption>
</NativeSelectOptGroup>
</NativeSelect>
<NativeSelect disabled class="w-48">
<NativeSelectOption>Mainnet</NativeSelectOption>
</NativeSelect>
<NativeSelect aria-invalid="true" size="sm" class="w-48">
<NativeSelectOption value="">Required</NativeSelectOption>
</NativeSelect>
</div>
registry/ui/native-select.tsx
"use client";
import { cn } from "cn";
import type * as React from "react";
import { CaretDownIcon } from "@phosphor-icons/react";
import { type Elevation, useElevation } from "@/registry/edmi/ui/elevation";
// ✦ depth (v4): fields sink (-1) in layered mode; focus swaps the edge for the ring
const fieldElevation = {
sunken: "border-sk-bd bg-sk-bg shadow-sunken focus-visible:bg-card",
flat: "",
raised:
"border-transparent bg-[image:linear-gradient(var(--bv-face-b),var(--bv-face-b))] shadow-raised focus-visible:border-ring focus-visible:shadow-ring",
floating:
"border-transparent bg-[image:linear-gradient(var(--bv-face-b),var(--bv-face-b))] shadow-floating focus-visible:border-ring focus-visible:shadow-ring",
};
type NativeSelectProps = Omit<React.ComponentProps<"select">, "size"> & {
size?: "sm" | "default";
/** ✦ depth: sunken -1, flat 0, raised +1, floating +2. */
elevation?: Elevation;
};
function NativeSelect({
className,
size = "default",
elevation,
...props
}: NativeSelectProps) {
const level = useElevation(elevation, "field");
return (
<div
className={cn(
"group/native-select relative w-fit has-[select:disabled]:opacity-50",
className,
)}
data-slot="native-select-wrapper"
data-size={size}
>
<select
data-slot="native-select"
data-size={size}
className={cn(
"h-9 w-full min-w-0 appearance-none rounded-md border border-input bg-card pr-8 pl-3 text-sm text-foreground outline-none select-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:shadow-ring disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-muted aria-invalid:border-destructive aria-invalid:shadow-ring-error data-[size=sm]:h-8 data-[size=sm]:rounded-[7px]",
fieldElevation[level],
)}
{...props}
/>
<CaretDownIcon
className="pointer-events-none absolute top-1/2 right-2.5 size-4 -translate-y-1/2 text-muted-foreground select-none"
aria-hidden="true"
data-slot="native-select-icon"
/>
</div>
);
}
function NativeSelectOption({
className,
...props
}: React.ComponentProps<"option">) {
return (
<option
data-slot="native-select-option"
className={cn("bg-[Canvas] text-[CanvasText]", className)}
{...props}
/>
);
}
function NativeSelectOptGroup({
className,
...props
}: React.ComponentProps<"optgroup">) {
return (
<optgroup
data-slot="native-select-optgroup"
className={cn("bg-[Canvas] text-[CanvasText]", className)}
{...props}
/>
);
}
export { NativeSelect, NativeSelectOptGroup, NativeSelectOption };
registry/ui/native-select/NativeSelect.vue
<script setup lang="ts">
import type { AcceptableValue } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { ChevronDownIcon } from '@lucide/vue'
import { reactiveOmit, useVModel } from '@vueuse/core'
import { cn } from '@/registry/edmi/lib/utils'
import { type Elevation, useElevation } from '@/registry/edmi/ui/elevation'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<{
modelValue?: AcceptableValue | AcceptableValue[]
size?: 'sm' | 'default'
/** ✦ depth: sunken -1, flat 0, raised +1, floating +2 */
elevation?: Elevation
class?: HTMLAttributes['class']
}>(), {
size: 'default',
elevation: undefined,
})
const emit = defineEmits<{
'update:modelValue': AcceptableValue
}>()
const modelValue = useVModel(props, 'modelValue', emit, {
passive: true,
defaultValue: '',
})
const delegatedProps = reactiveOmit(props, 'class', 'size', 'elevation')
// ✦ depth (v4): fields sink (-1) in layered mode; focus swaps the edge for the ring
const fieldElevation = {
sunken: 'border-sk-bd bg-sk-bg shadow-sunken focus-visible:bg-card',
flat: '',
raised: 'border-transparent bg-[image:linear-gradient(var(--bv-face-b),var(--bv-face-b))] shadow-raised focus-visible:border-ring focus-visible:shadow-ring',
floating: 'border-transparent bg-[image:linear-gradient(var(--bv-face-b),var(--bv-face-b))] shadow-floating focus-visible:border-ring focus-visible:shadow-ring',
}
const level = useElevation(() => props.elevation, 'field')
</script>
<template>
<div
:class="cn('group/native-select relative w-fit has-[select:disabled]:opacity-50', props.class)"
data-slot="native-select-wrapper"
:data-size="size"
>
<select
v-bind="{ ...$attrs, ...delegatedProps }"
v-model="modelValue"
data-slot="native-select"
:data-size="size"
:class="cn('h-9 w-full min-w-0 appearance-none rounded-md border border-input bg-card pr-8 pl-3 text-sm text-foreground outline-none select-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:shadow-ring disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-muted aria-invalid:border-destructive aria-invalid:shadow-ring-error data-[size=sm]:h-8 data-[size=sm]:rounded-[7px]', fieldElevation[level])"
>
<slot />
</select>
<ChevronDownIcon
class="pointer-events-none absolute top-1/2 right-2.5 size-4 -translate-y-1/2 text-muted-foreground select-none"
aria-hidden="true"
data-slot="native-select-icon"
/>
</div>
</template>
registry/ui/native-select/NativeSelectOptGroup.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<optgroup data-slot="native-select-optgroup" :class="cn('bg-[Canvas] text-[CanvasText]', props.class)">
<slot />
</optgroup>
</template>
registry/ui/native-select/NativeSelectOption.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<option data-slot="native-select-option" :class="cn('bg-[Canvas] text-[CanvasText]', props.class)">
<slot />
</option>
</template>
registry/ui/native-select/index.ts
export { default as NativeSelect } from "./NativeSelect.vue";
export { default as NativeSelectOptGroup } from "./NativeSelectOptGroup.vue";
export { default as NativeSelectOption } from "./NativeSelectOption.vue";
src/lib/registry/ui/native-select/native-select-opt-group.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLOptgroupAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLOptgroupAttributes> = $props();
</script>
<optgroup
bind:this={ref}
data-slot="native-select-optgroup"
class={cn("bg-[Canvas] text-[CanvasText]", className)}
{...restProps}
>
{@render children?.()}
</optgroup>
src/lib/registry/ui/native-select/native-select-option.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLOptionAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLOptionAttributes> = $props();
</script>
<option
bind:this={ref}
data-slot="native-select-option"
class={cn("bg-[Canvas] text-[CanvasText]", className)}
{...restProps}
>
{@render children?.()}
</option>
src/lib/registry/ui/native-select/native-select.svelte
<script lang="ts">
import CaretDownIcon from "phosphor-svelte/lib/CaretDown";
import { cn, type WithElementRef } from "$lib/utils.js";
import { type Elevation, useElevation } from "$lib/registry/ui/elevation/index.js";
import type { HTMLSelectAttributes } from "svelte/elements";
type NativeSelectProps = Omit<WithElementRef<HTMLSelectAttributes>, "size"> & {
size?: "sm" | "default";
/** ✦ depth: sunken -1, flat 0, raised +1, floating +2. */
elevation?: Elevation;
};
let {
ref = $bindable(null),
value = $bindable(),
class: className,
size = "default",
elevation = "auto",
children,
...restProps
}: NativeSelectProps = $props();
// ✦ depth (v4): fields sink (-1) in layered mode; focus swaps the edge for the ring
const fieldElevation = {
sunken: "border-sk-bd bg-sk-bg shadow-sunken focus-visible:bg-card",
flat: "",
raised: "border-transparent bg-[image:linear-gradient(var(--bv-face-b),var(--bv-face-b))] shadow-raised focus-visible:border-ring focus-visible:shadow-ring",
floating: "border-transparent bg-[image:linear-gradient(var(--bv-face-b),var(--bv-face-b))] shadow-floating focus-visible:border-ring focus-visible:shadow-ring",
};
const level = useElevation(() => elevation, "field");
</script>
<div
class={cn(
"group/native-select relative w-fit has-[select:disabled]:opacity-50",
className
)}
data-slot="native-select-wrapper"
data-size={size}
>
<select
bind:value
bind:this={ref}
data-slot="native-select"
data-size={size}
class={cn(
"h-9 w-full min-w-0 appearance-none rounded-md border border-input bg-card pr-8 pl-3 text-sm text-foreground outline-none select-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:shadow-ring disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-muted aria-invalid:border-destructive aria-invalid:shadow-ring-error data-[size=sm]:h-8 data-[size=sm]:rounded-[7px]",
fieldElevation[level.current]
)}
{...restProps}
>
{@render children?.()}
</select>
<CaretDownIcon
class="pointer-events-none absolute top-1/2 right-2.5 size-4 -translate-y-1/2 text-muted-foreground select-none"
aria-hidden
data-slot="native-select-icon"
/>
</div>
src/lib/registry/ui/native-select/index.ts
import Root from "./native-select.svelte";
import OptGroup from "./native-select-opt-group.svelte";
import Option from "./native-select-option.svelte";
export {
OptGroup,
OptGroup as NativeSelectOptGroup,
Option,
Option as NativeSelectOption,
Root,
Root as NativeSelect,
};

See the Elevation guide for the levels, roles and layered mode.

Same levels as Input, applied to the select box: sunken (-1), flat, raised (+1), floating (+2).

StackBlitz ↗StackBlitz ↗StackBlitz ↗

Sunken (-1)

Flat (0)

Raised (+1)

Floating (+2)

Sunken (-1)

Flat (0)

Raised (+1)

Floating (+2)

Sunken (-1)

Flat (0)

Raised (+1)

Floating (+2)

Prop Type Default
size sm | default default
elevation "auto" | "sunken" | "flat" | "raised" | "floating" "auto"
  • NativeSelectOption and NativeSelectOptGroup wrap the native elements.