Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

Select

A single-select field opening a dropdown (MD) or picker sheet (iOS).

A single-select form control. The trigger renders as an input-like field; opening reveals a platform-appropriate list — an anchored dropdown on Material (flipping above when short on room) and a bottom-sheet picker on iOS.

Installation

npx touchcn add select

Usage

<tcn-select [(value)]="fruit" label="Favorite fruit" placeholder="Choose a fruit">
  <tcn-select-option value="apple">Apple</tcn-select-option>
  <tcn-select-option value="banana">Banana</tcn-select-option>
</tcn-select>
import { TcnSelect, TcnSelectOption } from '@/components/ui/select';

<TcnSelect value={fruit} onValueChange={setFruit} label="Favorite fruit">
  <TcnSelectOption value="apple">Apple</TcnSelectOption>
  <TcnSelectOption value="banana">Banana</TcnSelectOption>
</TcnSelect>
<script setup lang="ts">
import { TcnSelect, TcnSelectOption } from '@/components/ui/select';
</script>

<template>
  <TcnSelect v-model="fruit" label="Favorite fruit">
    <TcnSelectOption value="apple">Apple</TcnSelectOption>
    <TcnSelectOption value="banana">Banana</TcnSelectOption>
  </TcnSelect>
</template>

Props

Prop Type Default Description
value string | null null Selected value (two-way on Angular).
onValueChange (value: string) => void Selection callback (React).
label string '' Field label.
placeholder string 'Select…' Shown when nothing is selected.
disabled boolean false Disable the control.
Source · Angular
export * from './tcn-select';
import {
  booleanAttribute,
  Component,
  computed,
  contentChildren,
  ElementRef,
  inject,
  input,
  model,
} from '@angular/core';
import { TcnOverlayDirective } from '@touchcn/angular/overlay';
import { TcnAnchoredPositionDirective } from '@touchcn/angular/positioning';
import { TcnSelectListboxDirective } from '@touchcn/angular/select';

/**
 * Single-select form control. The trigger renders as an input-like field
 * (label · value · chevron). Opening reveals a platform-appropriate list: an
 * anchored dropdown on MD (flips above when short on room) and a bottom-sheet
 * picker on iOS — one panel, forked by the cascade. Behaviour is composed from
 * the shared overlay primitive (focus trap, scroll lock, Escape, backdrop) plus
 * the engine listbox directive (placement measurement, arrow-key roving).
 *
 * Options are projected `tcn-select-option`s; the selected value is a two-way
 * `value` model.
 */
@Component({
  selector: 'tcn-select',
  imports: [TcnOverlayDirective, TcnAnchoredPositionDirective, TcnSelectListboxDirective],
  template: `
    <div class="tcn-select relative block">
      @if (label()) {
        <span class="tcn-select-label" [id]="labelId">{{ label() }}</span>
      }
      <button
        #trigger
        type="button"
        class="tcn-select-trigger"
        [attr.aria-expanded]="open()"
        [attr.aria-labelledby]="label() ? labelId : null"
        aria-haspopup="listbox"
        [disabled]="disabled()"
        (click)="toggle()"
      >
        <span class="tcn-select-value" [class.tcn-select-placeholder]="!selectedLabel()">
          {{ selectedLabel() || placeholder() }}
        </span>
        <svg class="tcn-select-chevron" width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
          <path d="m6 9 6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
        </svg>
      </button>

      <div class="tcn-select-overlay" [class.pointer-events-none]="!open()" [attr.data-state]="state()">
        <div class="tcn-overlay-backdrop tcn-select-backdrop fixed inset-0 z-40" (click)="overlay.dismiss()"></div>
        <div
          #overlay="tcnOverlay"
          tcnOverlay
          tcnAnchoredPosition
          [(open)]="open"
          [trigger]="trigger"
          class="tcn-overlay-panel tcn-select-panel z-50"
        >
          <div
            tcnSelectListbox
            role="listbox"
            [attr.aria-labelledby]="label() ? labelId : null"
          >
            <ng-content />
          </div>
        </div>
      </div>
    </div>
  `,
  host: { class: 'block' },
})
export class TcnSelect {
  readonly value = model<string | null>(null);
  readonly placeholder = input('Select…');
  readonly label = input('');
  readonly disabled = input(false, { transform: booleanAttribute });
  readonly open = model(false);

  protected readonly labelId = `tcn-select-label-${nextId++}`;

  private readonly options = contentChildren(TcnSelectOption);

  protected readonly state = computed(() => (this.open() ? 'open' : 'closed'));

  protected readonly selectedLabel = computed(() => {
    const selected = this.options().find((option) => option.value() === this.value());
    return selected?.text() ?? '';
  });

  select(value: string): void {
    this.value.set(value);
    this.open.set(false);
  }

  protected toggle(): void {
    this.open.update((open) => !open);
  }
}

let nextId = 0;

/** A single option projected into a `tcn-select`. */
@Component({
  selector: 'tcn-select-option',
  template: `
    <button
      type="button"
      role="option"
      class="tcn-select-option"
      [attr.aria-selected]="selected()"
      [attr.aria-disabled]="disabled() || null"
      [disabled]="disabled()"
      (click)="pick()"
    >
      <span class="tcn-select-option-label"><ng-content /></span>
      <svg class="tcn-select-option-check" width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
        <path d="M5 12l5 5L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
      </svg>
    </button>
  `,
  host: { class: 'block' },
})
export class TcnSelectOption {
  private readonly select = inject(TcnSelect);
  private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);

  readonly value = input.required<string>();
  readonly disabled = input(false, { transform: booleanAttribute });

  protected readonly selected = computed(() => this.select.value() === this.value());

  /** The option's visible text, used by the trigger to show the selected label. */
  text(): string {
    return this.host.nativeElement.textContent?.trim() ?? '';
  }

  protected pick(): void {
    if (!this.disabled()) {
      this.select.select(this.value());
    }
  }
}
Source · React
export * from './tcn-select';
import { Children, createContext, isValidElement, useContext, useId, useRef, useState } from 'react';
import type { ReactElement, ReactNode } from 'react';
import { useListboxKeyNav, useMenuPosition, useOverlayPresence } from '@touchcn/react';

interface SelectContextValue {
  value: string | null;
  onSelect(value: string): void;
}

const SelectContext = createContext<SelectContextValue | null>(null);

export interface TcnSelectProps {
  value: string | null;
  onValueChange(value: string): void;
  placeholder?: string;
  label?: string;
  disabled?: boolean;
  children?: ReactNode;
}

interface TcnSelectOptionProps {
  value: string;
  disabled?: boolean;
  children?: ReactNode;
}

/**
 * Single-select form control. The trigger renders as an input-like field
 * (label · value · chevron). Opening reveals a platform-appropriate list: an
 * anchored dropdown on MD (flips above when short on room) and a bottom-sheet
 * picker on iOS — one panel, forked by the cascade.
 *
 * Hand-rolled on the engine overlay/presence primitives rather than
 * `@radix-ui/react-select`: Radix Select owns a single popper presentation and
 * its own trigger/positioning contract, which fights the dual iOS-sheet / MD-
 * dropdown markup this component forks with `theme.css`. `useOverlayPresence`
 * (mount/animate), `useMenuPosition` (flip) and `useListboxKeyNav` (roving
 * focus + Escape) keep the copied markup identical to the Angular component.
 */
export function TcnSelect({
  value,
  onValueChange,
  placeholder = 'Select…',
  label,
  disabled,
  children,
}: TcnSelectProps) {
  const [open, setOpen] = useState(false);
  const triggerRef = useRef<HTMLButtonElement>(null);
  const { present, active, panelRef } = useOverlayPresence(open);
  const placement = useMenuPosition(triggerRef, active);
  const close = () => setOpen(false);
  const { onKeyDown } = useListboxKeyNav(panelRef, active, close);
  const labelId = useId();

  const selected = Children.toArray(children).find(
    (child): child is ReactElement<TcnSelectOptionProps> =>
      isValidElement<TcnSelectOptionProps>(child) && child.props.value === value,
  );
  const selectedLabel = selected?.props.children;

  const contextValue: SelectContextValue = {
    value,
    onSelect: (next) => {
      onValueChange(next);
      setOpen(false);
    },
  };

  return (
    <div className="tcn-select relative block">
      {label && (
        <span className="tcn-select-label" id={labelId}>
          {label}
        </span>
      )}
      <button
        ref={triggerRef}
        type="button"
        className="tcn-select-trigger"
        aria-expanded={open}
        aria-labelledby={label ? labelId : undefined}
        aria-haspopup="listbox"
        disabled={disabled}
        onClick={() => setOpen((prev) => !prev)}
      >
        <span className={`tcn-select-value${selectedLabel ? '' : ' tcn-select-placeholder'}`}>
          {selectedLabel || placeholder}
        </span>
        <svg className="tcn-select-chevron" width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
          <path d="m6 9 6 6 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
        </svg>
      </button>

      {present && (
        <div className="tcn-select-overlay" data-state={active ? 'open' : 'closed'}>
          <div className="tcn-overlay-backdrop tcn-select-backdrop fixed inset-0 z-40" onClick={close} />
          <div
            ref={panelRef}
            role="listbox"
            aria-labelledby={label ? labelId : undefined}
            data-placement={placement}
            className="tcn-overlay-panel tcn-select-panel z-50"
            onKeyDown={onKeyDown}
          >
            <SelectContext.Provider value={contextValue}>{children}</SelectContext.Provider>
          </div>
        </div>
      )}
    </div>
  );
}

/** A single option projected into a `TcnSelect`. */
export function TcnSelectOption({ value, disabled, children }: TcnSelectOptionProps) {
  const context = useContext(SelectContext);
  const selected = context?.value === value;
  return (
    <button
      type="button"
      role="option"
      className="tcn-select-option"
      aria-selected={selected}
      aria-disabled={disabled || undefined}
      disabled={disabled}
      onClick={() => !disabled && context?.onSelect(value)}
    >
      <span className="tcn-select-option-label">{children}</span>
      <svg className="tcn-select-option-check" width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
        <path d="M5 12l5 5L19 7" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
      </svg>
    </button>
  );
}
Source · Vue
<script setup lang="ts">
import { computed, defineComponent, provide, ref, useId, useSlots } from 'vue';
import { resolveSelectedLabel, useListboxKeyNav, useMenuPosition, useOverlayPresence } from '@touchcn/vue';
import { TcnSelectKey } from './context';

withDefaults(defineProps<{ placeholder?: string; label?: string; disabled?: boolean }>(), {
  placeholder: 'Select…',
});

/** Two-way bound selected value (`v-model`). */
const model = defineModel<string | null>({ default: null });

/**
 * Single-select form control. The trigger renders as an input-like field
 * (label · value · chevron). Opening reveals a platform-appropriate list: an
 * anchored dropdown on MD (flips above when short on room) and a bottom-sheet
 * picker on iOS — one panel, forked by the cascade.
 *
 * Hand-rolled on the engine overlay/presence primitives rather than a reka-ui
 * Select: reka's select owns a single popper presentation and its own
 * trigger/positioning contract, which fights the dual iOS-sheet / MD-dropdown
 * markup this component forks with `theme.css`. `useOverlayPresence`
 * (mount/animate), `useMenuPosition` (flip) and `useListboxKeyNav` (roving focus
 * + Escape) keep the copied markup identical to the React / Angular component.
 */
const slots = useSlots();
const open = ref(false);
const triggerRef = ref<HTMLElement | null>(null);
const panelRef = ref<HTMLElement | null>(null);

const { present, active, setPanel } = useOverlayPresence(open);
const placement = useMenuPosition(triggerRef, active);
const close = (): void => {
  open.value = false;
};
const { onKeyDown } = useListboxKeyNav(panelRef, active, close);
const labelId = useId();

const setPanelBoth = (el: unknown): void => {
  const node = el instanceof HTMLElement ? el : null;
  setPanel(node);
  panelRef.value = node;
};

const selectedLabel = computed(() => resolveSelectedLabel(slots.default?.(), model.value));
const SelectedLabel = defineComponent({ name: 'TcnSelectValue', render: () => selectedLabel.value });

provide(TcnSelectKey, {
  value: computed(() => model.value),
  select: (next: string) => {
    model.value = next;
    open.value = false;
  },
});
</script>

<template>
  <div class="tcn-select relative block">
    <span v-if="label" class="tcn-select-label" :id="labelId">{{ label }}</span>
    <button
      ref="triggerRef"
      type="button"
      class="tcn-select-trigger"
      :aria-expanded="open"
      :aria-labelledby="label ? labelId : undefined"
      aria-haspopup="listbox"
      :disabled="disabled"
      @click="open = !open"
    >
      <span :class="['tcn-select-value', !selectedLabel && 'tcn-select-placeholder']">
        <SelectedLabel v-if="selectedLabel" />
        <template v-else>{{ placeholder }}</template>
      </span>
      <svg class="tcn-select-chevron" width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
        <path d="m6 9 6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
      </svg>
    </button>

    <div v-if="present" class="tcn-select-overlay" :data-state="active ? 'open' : 'closed'">
      <div class="tcn-overlay-backdrop tcn-select-backdrop fixed inset-0 z-40" @click="close" />
      <div
        :ref="setPanelBoth"
        role="listbox"
        :aria-labelledby="label ? labelId : undefined"
        :data-placement="placement"
        class="tcn-overlay-panel tcn-select-panel z-50"
        @keydown="onKeyDown"
      >
        <slot />
      </div>
    </div>
  </div>
</template>
<script setup lang="ts">
import { computed, inject } from 'vue';
import { TcnSelectKey } from './context';

const props = defineProps<{ value: string; disabled?: boolean }>();

/** A single option projected into a `TcnSelect`. */
const context = inject(TcnSelectKey, null);
const selected = computed(() => context?.value.value === props.value);

const onClick = (): void => {
  if (!props.disabled) {
    context?.select(props.value);
  }
};
</script>

<template>
  <button
    type="button"
    role="option"
    class="tcn-select-option"
    :aria-selected="selected"
    :aria-disabled="disabled || undefined"
    :disabled="disabled"
    @click="onClick"
  >
    <span class="tcn-select-option-label"><slot /></span>
    <svg class="tcn-select-option-check" width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <path d="M5 12l5 5L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
    </svg>
  </button>
</template>
import type { ComputedRef, InjectionKey } from 'vue';

export interface TcnSelectContext {
  /** The currently selected value. */
  value: ComputedRef<string | null>;
  /** Commit a selection and close the menu. */
  select(value: string): void;
}

/** Injection key shared between `TcnSelect` and its projected `TcnSelectOption`s. */
export const TcnSelectKey: InjectionKey<TcnSelectContext> = Symbol('tcn-select');
export { default as TcnSelect } from './TcnSelect.vue';
export { default as TcnSelectOption } from './TcnSelectOption.vue';

Last updated on July 24, 2026

Was this page helpful?