Select
Component

Select

A custom dropdown select component with full keyboard navigation and accessibility support. Supports strings, enums, and complex objects.

Demo

Selected: None

Features

  • Generic type support (string, enums, objects)
  • Full keyboard navigation with typeahead
  • Grouped items with labels
  • Flexible positioning with collision detection
  • EditForm integration with validation
  • Controlled and uncontrolled modes
  • WCAG compliant with proper ARIA attributes

Installation

bash
dotnet add package SummitUI

Anatomy

Import the components and structure them as follows:

razor
<SmSelectRoot TValue="string">
    <SmSelectTrigger TValue="string">
        <SmSelectValue TValue="string" Placeholder="Select an option..." />
    </SmSelectTrigger>
    <SmSelectPortal TValue="string">
        <SmSelectContent TValue="string">
            <SmSelectViewport TValue="string">
                <SmSelectItem TValue="string" Value="@("option-1")">
                    <SmSelectItemText>Option 1</SmSelectItemText>
                </SmSelectItem>
            </SmSelectViewport>
        </SmSelectContent>
    </SmSelectPortal>
</SmSelectRoot>

Sub-components

SelectRoot<TValue>

Generic root container managing select state.

SelectTrigger<TValue>

Button that opens the dropdown (combobox role).

SelectValue<TValue>

Displays selected value or placeholder text.

SelectPortal<TValue>

Renders content in fixed-position container.

SelectContent<TValue>

Floating listbox panel with positioning.

SelectViewport<TValue>

Scrollable container for items.

SelectItem<TValue>

Selectable option with option role.

SelectItemText

Text content wrapper for items.

SelectGroup<TValue>

Groups related items together.

SelectGroupLabel

Label for a group of items.

API Reference

SelectRoot<TValue>

The root component that manages select state and provides cascading context.

Property Type Default Description
Value TValue? null Controlled selected value
DefaultValue TValue? null Default value (uncontrolled)
ValueChanged EventCallback<TValue?> - Value change callback
ValueExpression Expression<Func<TValue?>>? null For EditForm validation
OnValueChange EventCallback<TValue?> - Alternative value change callback
Open bool? null Controlled open state
DefaultOpen bool false Default open state
OpenChanged EventCallback<bool> - Open state change callback
Disabled bool false Disable entire select
Required bool false For form validation
Invalid bool false For error styling
Name string? null Form field name for hidden input

SelectTrigger<TValue>

Button that opens the dropdown list.

Property Type Default Description
As string "button" HTML element to render
AriaLabel string? null Direct aria-label
AriaLabelledBy string? null ID of external label

SelectValue<TValue>

Displays the selected value or placeholder.

Property Type Default Description
Placeholder string? null Placeholder when no value selected
ChildContent RenderFragment? null Custom content

SelectContent<TValue>

Floating listbox panel with positioning options.

Property Type Default Description
As string "div" HTML element to render
Side Side Bottom Placement side
SideOffset int 4 Offset from trigger (px)
Align Align Start Alignment along side axis
AlignOffset int 0 Alignment offset (px)
AvoidCollisions bool true Avoid viewport boundaries
CollisionPadding int 8 Viewport padding (px)
EscapeKeyBehavior EscapeKeyBehavior Close Escape key behavior
OutsideClickBehavior OutsideClickBehavior Close Outside click behavior
OnInteractOutside EventCallback - Outside click callback
OnEscapeKeyDown EventCallback - Escape key callback

SelectItem<TValue>

Selectable option within the dropdown.

Property Type Default Description
Valuerequired TValue - Value of this item
Key string? null Optional string key for JS interop
Label string? null Label for typeahead and display
Disabled bool false Disable item
OnSelect EventCallback - Selection callback

Enums

Side

csharp
public enum Side
{
    Top,
    Right,
    Bottom,
    Left
}

Align

csharp
public enum Align
{
    Start,
    Center,
    End
}

EscapeKeyBehavior

csharp
public enum EscapeKeyBehavior
{
    Close,
    Ignore
}

OutsideClickBehavior

csharp
public enum OutsideClickBehavior
{
    Close,
    Ignore
}

Examples

Basic String Select

razor
@code {
    private string? selectedFruit;
}

<SmSelectRoot TValue="string" @bind-Value="selectedFruit">
    <SmSelectTrigger TValue="string" class="select-trigger">
        <SmSelectValue TValue="string" Placeholder="Select a fruit..." />
        <span class="select-icon">▼</span>
    </SmSelectTrigger>
    <SmSelectPortal TValue="string">
        <SmSelectContent TValue="string" class="select-content" SideOffset="4">
            <SmSelectViewport TValue="string" class="select-viewport">
                <SmSelectItem TValue="string" Value="@("apple")" Label="Apple">
                    <SmSelectItemText>Apple</SmSelectItemText>
                </SmSelectItem>
                <SmSelectItem TValue="string" Value="@("banana")" Label="Banana">
                    <SmSelectItemText>Banana</SmSelectItemText>
                </SmSelectItem>
                <SmSelectItem TValue="string" Value="@("cherry")" Label="Cherry">
                    <SmSelectItemText>Cherry</SmSelectItemText>
                </SmSelectItem>
            </SmSelectViewport>
        </SmSelectContent>
    </SmSelectPortal>
</SmSelectRoot>

<p>Selected: @(selectedFruit ?? "None")</p>

With Enum Values

Use strongly-typed enum values.

razor
@code {
    public enum Priority { Low, Medium, High, Critical }
    private Priority selectedPriority = Priority.Medium;
}

<SmSelectRoot TValue="Priority" @bind-Value="selectedPriority">
    <SmSelectTrigger TValue="Priority" class="select-trigger">
        <SmSelectValue TValue="Priority" Placeholder="Select priority..." />
    </SmSelectTrigger>
    <SmSelectPortal TValue="Priority">
        <SmSelectContent TValue="Priority" class="select-content">
            <SmSelectViewport TValue="Priority">
                <SmSelectItem TValue="Priority" Value="Priority.Low" Label="Low">
                    <SmSelectItemText>Low Priority</SmSelectItemText>
                </SmSelectItem>
                <SmSelectItem TValue="Priority" Value="Priority.Medium" Label="Medium">
                    <SmSelectItemText>Medium Priority</SmSelectItemText>
                </SmSelectItem>
                <SmSelectItem TValue="Priority" Value="Priority.High" Label="High">
                    <SmSelectItemText>High Priority</SmSelectItemText>
                </SmSelectItem>
                <SmSelectItem TValue="Priority" Value="Priority.Critical" Label="Critical">
                    <SmSelectItemText>Critical Priority</SmSelectItemText>
                </SmSelectItem>
            </SmSelectViewport>
        </SmSelectContent>
    </SmSelectPortal>
</SmSelectRoot>

With Complex Objects

Use custom objects as values.

razor
@code {
    public record Country(string Code, string Name);
    
    private List<Country> countries = new()
    {
        new("US", "United States"),
        new("UK", "United Kingdom"),
        new("CA", "Canada"),
        new("AU", "Australia")
    };
    
    private Country? selectedCountry;
}

<SmSelectRoot TValue="Country" @bind-Value="selectedCountry">
    <SmSelectTrigger TValue="Country" class="select-trigger">
        <SmSelectValue TValue="Country" Placeholder="Select a country..." />
    </SmSelectTrigger>
    <SmSelectPortal TValue="Country">
        <SmSelectContent TValue="Country" class="select-content">
            <SmSelectViewport TValue="Country">
                @foreach (var country in countries)
                {
                    <SmSelectItem TValue="Country" 
                                Value="country" 
                                Key="@country.Code" 
                                Label="@country.Name">
                        <SmSelectItemText>@country.Name (@country.Code)</SmSelectItemText>
                    </SmSelectItem>
                }
            </SmSelectViewport>
        </SmSelectContent>
    </SmSelectPortal>
</SmSelectRoot>

Grouped Items

Organize items into logical groups.

razor
<SmSelectRoot TValue="string" @bind-Value="selectedFood">
    <SmSelectTrigger TValue="string" class="select-trigger">
        <SmSelectValue TValue="string" Placeholder="Select food..." />
    </SmSelectTrigger>
    <SmSelectPortal TValue="string">
        <SmSelectContent TValue="string" class="select-content">
            <SmSelectViewport TValue="string">
                <SelectGroup TValue="string">
                    <SelectGroupLabel class="select-group-label">Fruits</SelectGroupLabel>
                    <SmSelectItem TValue="string" Value="@("apple")" Label="Apple">
                        <SmSelectItemText>Apple</SmSelectItemText>
                    </SmSelectItem>
                    <SmSelectItem TValue="string" Value="@("banana")" Label="Banana">
                        <SmSelectItemText>Banana</SmSelectItemText>
                    </SmSelectItem>
                </SelectGroup>
                <SelectGroup TValue="string">
                    <SelectGroupLabel class="select-group-label">Vegetables</SelectGroupLabel>
                    <SmSelectItem TValue="string" Value="@("carrot")" Label="Carrot">
                        <SmSelectItemText>Carrot</SmSelectItemText>
                    </SmSelectItem>
                    <SmSelectItem TValue="string" Value="@("broccoli")" Label="Broccoli">
                        <SmSelectItemText>Broccoli</SmSelectItemText>
                    </SmSelectItem>
                </SelectGroup>
            </SmSelectViewport>
        </SmSelectContent>
    </SmSelectPortal>
</SmSelectRoot>

EditForm Integration

Use with Blazor EditForm for validation.

razor
@code {
    public class FormModel
    {
        [Required(ErrorMessage = "Category is required")]
        public string? Category { get; set; }
    }
    
    private FormModel formModel = new();
    private EditContext? editContext;
    
    protected override void OnInitialized()
    {
        editContext = new EditContext(formModel);
    }
    
    private void HandleSubmit()
    {
        if (editContext!.Validate())
        {
            // Process form
        }
    }
}

<EditForm EditContext="editContext" OnSubmit="HandleSubmit">
    <DataAnnotationsValidator />
    
    <div class="form-field">
        <label for="category">Category</label>
        <SmSelectRoot TValue="string" 
                    @bind-Value="formModel.Category" 
                    Name="category" 
                    Required="true">
            <SmSelectTrigger TValue="string" class="select-trigger" id="category">
                <SmSelectValue TValue="string" Placeholder="Select category..." />
            </SmSelectTrigger>
            <SmSelectPortal TValue="string">
                <SmSelectContent TValue="string" class="select-content">
                    <SmSelectViewport TValue="string">
                        <SmSelectItem TValue="string" Value="@("electronics")" Label="Electronics">
                            <SmSelectItemText>Electronics</SmSelectItemText>
                        </SmSelectItem>
                        <SmSelectItem TValue="string" Value="@("clothing")" Label="Clothing">
                            <SmSelectItemText>Clothing</SmSelectItemText>
                        </SmSelectItem>
                        <SmSelectItem TValue="string" Value="@("books")" Label="Books">
                            <SmSelectItemText>Books</SmSelectItemText>
                        </SmSelectItem>
                    </SmSelectViewport>
                </SmSelectContent>
            </SmSelectPortal>
        </SmSelectRoot>
        <ValidationMessage For="@(() => formModel.Category)" />
    </div>
    
    <button type="submit">Submit</button>
</EditForm>

Disabled State

Disable entire select or individual items.

razor
@* Disabled entire select *@
<SmSelectRoot TValue="string" Disabled="true">
    <SmSelectTrigger TValue="string" class="select-trigger">
        <SmSelectValue TValue="string" Placeholder="Disabled..." />
    </SmSelectTrigger>
    ...
</SmSelectRoot>

@* Disabled individual items *@
<SmSelectRoot TValue="string" @bind-Value="selectedValue">
    <SmSelectTrigger TValue="string" class="select-trigger">
        <SmSelectValue TValue="string" Placeholder="Select..." />
    </SmSelectTrigger>
    <SmSelectPortal TValue="string">
        <SmSelectContent TValue="string" class="select-content">
            <SmSelectViewport TValue="string">
                <SmSelectItem TValue="string" Value="@("option1")" Label="Option 1">
                    <SmSelectItemText>Option 1</SmSelectItemText>
                </SmSelectItem>
                <SmSelectItem TValue="string" Value="@("option2")" Label="Option 2" Disabled="true">
                    <SmSelectItemText>Option 2 (Disabled)</SmSelectItemText>
                </SmSelectItem>
                <SmSelectItem TValue="string" Value="@("option3")" Label="Option 3">
                    <SmSelectItemText>Option 3</SmSelectItemText>
                </SmSelectItem>
            </SmSelectViewport>
        </SmSelectContent>
    </SmSelectPortal>
</SmSelectRoot>

Styling

Data Attributes

Attribute Values Description
data-state "open" | "closed" Dropdown open state (on trigger/content)
data-state "checked" | "unchecked" Item selection state
data-highlighted Present when focused Item is keyboard-focused
data-disabled Present when disabled Item or trigger is disabled
data-placeholder Present when no value Showing placeholder text
data-invalid Present when invalid For form validation errors

CSS Example

css
.select-trigger {
    display: flex;
    align-items: center;
    justify-content: space-between;
    width: 200px;
    padding: 8px 12px;
    background: white;
    border: 1px solid #ccc;
    border-radius: 6px;
    cursor: pointer;
}

.select-trigger[data-state="open"] {
    border-color: #0066cc;
    box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.2);
}

.select-trigger[data-disabled] {
    opacity: 0.5;
    cursor: not-allowed;
}

.select-trigger[data-placeholder] {
    color: #999;
}

.select-content {
    background: white;
    border: 1px solid #e0e0e0;
    border-radius: 8px;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
    overflow: hidden;
}

.select-viewport {
    padding: 4px;
    max-height: 300px;
    overflow-y: auto;
}

.select-item {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 8px 12px;
    border-radius: 4px;
    cursor: pointer;
}

.select-item[data-highlighted] {
    background: rgb(var(--su-accent));
    color: rgb(var(--su-accent-foreground));
}

.select-item[data-state="checked"] {
    background: #e6f0ff;
}

.select-item[data-disabled] {
    opacity: 0.5;
    cursor: not-allowed;
}

.select-group-label {
    padding: 8px 12px;
    font-size: 12px;
    font-weight: 600;
    color: #666;
    text-transform: uppercase;
}

Accessibility

Keyboard Navigation

Key Action
Enter / Space Open dropdown or select highlighted item
ArrowDown Open dropdown or move to next item
ArrowUp Move to previous item
Home Move to first item
End Move to last item
Escape Close dropdown
A-Z / a-z Typeahead - jump to matching item

Typeahead

Typing characters while the dropdown is open will jump to items that match the typed text. The search buffer resets after a short delay.

ARIA Attributes

  • SelectTrigger: Has role="combobox", aria-haspopup="listbox", and aria-expanded
  • SelectContent: Has role="listbox"
  • SelectItem: Has role="option" with aria-selected
  • SelectGroup: Has role="group" with aria-labelledby
  • Disabled items: Have aria-disabled
An unhandled error has occurred. Reload X