Canvas Design System
Main Site Tokens

Select

Picks one value from a known list, wrapping the native <select> so it matches the rest of the form.

When to Use

Use when: The user picks exactly one option from four or more known values — a category, a workshop type, a filter. The list is fixed and short enough to scroll.
Don't use when: There are three or fewer options — use Radio Card Group so they're all visible. For dozens of values, use a searchable input; for on/off, use Toggle.

Variants

Basic

Options are flat value => label pairs. A placeholder adds a leading prompt option.

expand_more
<?= ws_select('category', [
    'ideation' => 'Ideation',
    'strategy' => 'Strategy',
    'team'     => 'Team Building',
], [
    'label'       => 'Category',
    'placeholder' => 'Select a category…',
    'required'    => true,
]) ?>
info The options shape here is not the same as ws_input(type: 'select'), which wants a list of arrays. When you need a dropdown, this helper is the one to reach for — see Input.

Sizes

expand_more
expand_more
expand_more
<?= ws_select('field', $opts, ['label' => 'Small', 'size' => 'sm']) ?>

Pre-selected value

Pass value to mark the current selection — essential when redisplaying a form after a validation failure.

expand_more
<?= ws_select('workshop_type', $types, [
    'label' => 'Workshop type',
    'value' => $plan['type'] ?? '',
]) ?>

States

expand_more
Choose the option that best fits your needs
expand_more
Please select an option
expand_more
StateBehavior
DefaultBordered field with the chevron drawn as a separate .ws-select__arrow element, since native dropdown arrows can't be styled.
HoverBorder shifts to --gray-300.
Focus--input-border-focus plus the --input-ring halo, matching Input exactly.
ErrorDerived from a non-empty error — there's no separate state option. Sets aria-invalid="true", points aria-describedby at the message, and renders it with role="alert".
HelperShown below the field. Like Input, the error takes the describedby slot when both are present.
DisabledNative disabled: greyed, unfocusable, not submitted.
OpenThe dropdown list is rendered by the OS, so its appearance isn't ours to style — and doesn't need to be.
<?= ws_select('category', $opts, ['error'  => 'Please select an option']) ?>
<?= ws_select('category', $opts, ['helper' => 'Helpful description']) ?>
warning Naming inconsistency: help text is helper here but hint on Input. The two aren't interchangeable — passing the wrong one is silently ignored. Form Row also uses helper.

Real-World Usage

A category filter above a library listing: options built from the database, the current query-string value pre-selected, and a submit-on-change handler passed through attrs.

expand_more
<?php
$opts = ['all' => 'All categories'];
foreach ($categories as $c) {
    $opts[$c['slug']] = $c['name'] . ' (' . $c['exercise_count'] . ')';
}
?>
<?= ws_select('category', $opts, [
    'label' => 'Filter by category',
    'value' => $_GET['category'] ?? 'all',
    'attrs' => ['onchange' => 'this.form.submit()'],
]) ?>

Options

OptionTypeDefaultPurpose
$namestring''First positional. Field name, and the default id.
$selectOptionsarray[]Second positional. Flat value => label pairs.
labelstringnullVisible label. Effectively required.
valuestring''Pre-selected value
placeholderstringnullLeading prompt option
requiredboolfalseNative required attribute
disabledboolfalseDisabled state
errorstringnullError message. Non-empty is the error state.
helperstringnullHelp text below the field. Note: helper, not hint.
sizestring'md'sm | md | lg
idstring$nameElement ID, used to bind the label
classstring''Additional CSS classes
attrsarray[]Extra HTML attributes — where onchange and data-* go

Accessibility

ConcernBehavior
SemanticsA native <select>. Keyboard interaction, type-ahead, and the platform's own picker on mobile all come free — the main reason not to build a custom dropdown.
Labelling<label for> bound to the field id. Without label the control is unnamed; a placeholder option doesn't count.
Errorsaria-invalid="true" on the field, aria-describedby pointing at the message, and role="alert" on the message so it's announced when it appears.
KeyboardNative throughout: Tab to reach, arrows or typing to change, Enter/Esc to commit or cancel on platforms with a popup list.
Placeholder optionRendered as a real option. If the field is required, make sure its value is empty so native validation rejects it.
Submit on changeReloading the page on change moves focus and can strand keyboard users mid-list. Prefer an explicit apply button, or restore focus after the reload.
ContrastField text uses --text-dark and the arrow --gray-400. The arrow is decorative — the control is identifiable without it.

Tokens

TokenUsed for
--bg-surfaceField background
--gray-200 / --gray-300Border, resting and hover
--gray-400Chevron colour
--gray-50Disabled fill
--input-border-focus / --input-ring / --input-ring-widthFocus border and halo, shared with Input
--focus-ring-width / --focus-ring-offsetShared focus geometry
--radius-xlField corners
--text-darkSelected value text
--text-body / --text-ui / --text-meta / --text-h3Type scale across sizes
--font-bodyBody face
--transition-fastBorder and ring transitions

CSS Classes

ClassPurpose
.ws-selectThe native <select>
.ws-select--sm / --lgSize modifiers (md emits nothing)
.ws-select-wrapPositioning context for the field and its arrow
.ws-select__arrowThe chevron overlay
.ws-fieldOuter field wrapper, shared with Input
.ws-field--errorError state on the wrapper
.ws-field__label / __error / __helperLabel, error, and helper text, shared with Input
info ws_input(type: 'select') emits .ws-select-wrapper — a different class from this component's .ws-select-wrap. Another reason to standardise on ws_select() for dropdowns.

Files

FilePurpose
includes/components/helpers.phpws_select() helper function
includes/components/select.phpTemplate — option loop and ARIA wiring
includes/components/components.cssStyles (.ws-select and shared .ws-field rules)