Forms and inputs
Field anatomy, text and select inputs, checkboxes and toggles, the date and time pickers, faceted search, validation, and form layout.
Saga forms are quiet: a label above, a bordered white box, a blue focus,
clay for errors. Native controls are welcome where they fit: a short plain
<select> or a single <input type="date"> is accessible by default and
handles keyboard, touch and viewport edges for free. Reach for the painted
.saga-select listbox and .saga-datepicker when the control needs search,
groups, annotations, a range, or one look across browsers (see "Native or
painted?" under Select). One caveat to weigh: a native popup paints outside
the tab, so it is missing from a tab share.
Field anatomy#
<div class="saga-field">
<label class="saga-label" for="title">Session Title</label>
<input class="saga-input" id="title" placeholder="Out of window test">
<p class="saga-help">Shown to tutors on their schedule.</p>
</div>
- Label above the control,
--text-sm,--weight-semibold,--color-text, Title Case,--space-xsbelow it. Every control has one; a search box may use a visually hidden label plus placeholder. - Control at full width of its column.
- Help (
.saga-help) below,--text-sm,--color-text-secondary,--space-xsabove. Optional. One sentence. - Error (
.saga-error) replaces help (same slot;data-invalidon the field hides the help),--text-sm,--color-danger, with a small warning glyph. The control's border turns--input-border-color-invalid. - Fields stack
--space-mdapart; related fields sit side by side on one row with--space-mdbetween them (Tutoring Period | When | Time in the Edit Session modal).
A field-level annotation that is computed rather than typed (the "DURATION
30m" readout next to the time pair) is a tiny uppercase label in
--text-xs --color-text-secondary with the value in --weight-semibold
beneath it.
Text input#
| Token | Value |
|---|---|
--input-height |
2.4rem |
--input-padding |
0.3rem 0.75rem |
--input-radius |
--radius-md (6px) |
--input-bg |
white |
--input-border-color |
--color-border-strong (#d9d9d9), 1px |
--input-border-color-hover |
--color-gray (#9f9f9f) |
--input-border-color-focus |
--color-primary, plus the global focus ring |
--input-border-color-invalid |
--color-danger |
--input-color-placeholder |
--saga-beige-dark (#a59c94), italic |
| text | inherits (--text-base), --color-text; .saga-input--sm drops to --text-sm at 80% height |
Placeholder text is italic and beige, so an empty field and a filled field are different at a glance even in a screenshot. Placeholders describe the format ("Search whole name, Student ID, school"), not the label.
.saga-textarea is the same box, minimum three lines, resizable vertically.
Select#
Both kinds of select share one box: .saga-select wraps either a native
<select> or a painted trigger, and both get the input border with a blue
caret at the right (DS-101, DS-124). The painted select is a trigger that
looks like an input and opens a painted listbox; it is the one to use when
the list needs search, groups, multi-select or rich rows:
- Trigger:
.saga-select > button.saga-select__trigger, the text-input box with a CSS-drawn down caret in--color-primaryat the right. Shows the chosen value in a<span>, or the placeholder (.is-placeholder) in italic beige.aria-haspopup="listbox",aria-expandedwhile open. - List:
.saga-select__menu, a white panel, 1px--color-border,--radius-md,--shadow-popover, anchored under the trigger at the trigger's width or wider, max height 18rem then scrolls. It flips above when there is no room below. Layer--z-dropdown. - Search row (
.saga-select__search) at the top once there are more than a handful of options: a.saga-searchwith a magnifier glyph and an italic "Search..." input, hairline rule below. - Rows:
.saga-select__option, either the<li role="option">itself or a<button role="option">inside it;--space-sm --space-mdpadding. Hover and the keyboard-active row (data-active) take--color-bg-tertiary. The selected row (aria-selected="true") is--color-primary-lightfill with--color-primarysemibold text (the "Demo Program 08/17" row inprogram_dropdown.png). A row that cannot be chosen isaria-disabled="true"in--color-text-disabled. - Footer slot (
.saga-select__footer) for an add action, separated by a hairline: the blue add-disc and "Add New Program". - Group headers inside the list are navy,
--text-xsbold, uppercase, with a hairline beneath; de-emphasized options are italic. .saga-select--inlinemakes the control shrink to its content (a filter in a toolbar);.saga-select--smtakes the--sminput metrics.
Keyboard: arrow keys move, Enter picks, Esc closes, typing filters. Multi-select rows carry a checkbox; single-select rows carry no indicator, the highlight is the mark.
<div class="saga-field">
<label class="saga-label" id="ratio-label">Tutor:Student Ratio</label>
<div class="saga-select">
<button type="button" class="saga-select__trigger" aria-haspopup="listbox" aria-expanded="false" aria-labelledby="ratio-label"><span>1:3</span></button>
<ul class="saga-select__menu" role="listbox" aria-labelledby="ratio-label" hidden>
<li class="saga-select__option" role="option" aria-selected="false">1:1</li>
<li class="saga-select__option" role="option" aria-selected="true">1:3</li>
<li class="saga-select__option" role="option" aria-selected="false">1:4</li>
</ul>
</div>
</div>
Native or painted? A native <select> wrapped in .saga-select takes
the same box and caret (appearance: none) and is the right control when the
shoe fits (DS-124):
- Native for a short plain list: roughly a dozen options or fewer, text only, single choice. Also for a form inside a modal, where a custom popover fights the dialog's stacking and viewport, and on mobile, where the platform picker beats anything painted. It is accessible by default and handles keyboard, viewport edges and touch for free.
- Painted (
.saga-select__trigger+.saga-select__menu) when the list needs search, groups, multi-select or rich option content, or when a dense toolbar wants a consistent painted look. Dash's program selector, with dozens of entries and a search row, is the canonical case. - Dash's own CI bans the native element outright. That is a repo-level ratchet dash chose for itself, not this guide's rule.
<div class="saga-select"><select aria-label="Ratio"><option>1:1</option><option selected>1:3</option></select></div>
Checkbox, radio, toggle#
All three are the native input drawn with appearance: none, sized by the
spacing tokens, wrapped in a label.saga-check that puts the text --space-sm
to the right (DS-116).
- Checkbox
.saga-checkbox: a--space-md(16px) square, 1px--input-border-color-hoverborder,--radius-xs, white. Checked fills the box--color-primaryand draws a white check inside; indeterminate fills it and draws a white bar. Hover turns the border blue. - Radio
.saga-radio: a--space-mdcircle, the same 1px ring. Selected fills--color-primarywith a white dot in the centre (DS-060). Radios in a row sit--space-mdapart; in a column,--space-sm. - Toggle
.saga-toggle(role="switch"): a 36 × 20px track (--space-xl + --space-xsby--space-md + --space-xs),--radius-full, with a--space-mdwhite knob inset--space-2xsfrom the track edge. Off is--color-gray-lighttrack; on is--color-primarytrack with the knob slid--space-mdto the right. The label follows ("Show Entire District"). - Disabled is a
--input-bg-disabledfill with a--color-gray-lightborder (checked and disabled:--color-grayfill), never opacity.
<label class="saga-check"><input type="checkbox" class="saga-checkbox" checked> Email me a copy</label>
<label class="saga-check"><input type="radio" name="pattern" class="saga-radio" checked> Same Every Week</label>
<label class="saga-check"><input type="checkbox" class="saga-toggle" role="switch"> Show Entire District</label>
Large choices become option cards (Same Every Week / Varies by Day Type;
the four Rotation Pattern tiles): a .saga-card with the radio or an icon in
the top-left, a --text-base semibold title, and a --text-sm secondary
description. The selected card gets a 2px --color-primary border and a
--color-primary-light fill. Use option cards when each choice needs a
sentence of explanation; use plain radios when the labels are enough.
Date picker#
A single plain date in a form is a native <input type="date"> carrying the
.saga-input class; it is accessible, localized and touch-friendly for free
(DS-125). Use .saga-datepicker when the day grid needs annotations (sessions
on a day, disabled days), a range, quick options, or the picker must look the
same in every browser. It is a .saga-datepicker__trigger that looks like an
input with a calendar glyph in --color-text-secondary at the right
("10/12/2026"), sized to its content rather than the column. Clicking opens a
.saga-datepicker__popover (date_picker.png):
- 264px wide, white, 1px
--color-border,--radius-lg,--space-mdpadding,--shadow-popover,--z-dropdown. .saga-datepicker__nav: a.saga-datepicker__monthlabel ("October 2026") in--text-smsemibold, centred, with small quiet chevron buttons in--color-primaryon either side..saga-datepicker__grid: seven 32px columns. The weekday row (.saga-datepicker__weekday) is--text-xssemibold--color-text-secondary, Su through Sa.- Day cells are
button.saga-datepicker__day, 32px squares,--text-sm,--radius-md, hover--color-bg-tertiary. Adjacent-month days are--muted(--color-text-muted); today is--today(bold). The selected day (aria-selected="true") is a--color-primaryfilled square with white text and a 2px--color-primary-lightring. Disabled days are--color-text-mutedwith no hover. - Keyboard: arrows move by day, PageUp and PageDown by month, Enter selects, Esc closes. Focus moves into the grid on open and back to the trigger on close.
<div class="saga-datepicker">
<button type="button" class="saga-datepicker__trigger" aria-haspopup="dialog" aria-expanded="true" aria-label="When: 10/12/2026">
<span>10/12/2026</span><iconify-icon icon="material-symbols-light:calendar-today-outline-rounded"></iconify-icon>
</button>
<div class="saga-datepicker__popover" role="dialog" aria-label="Choose a date">
<div class="saga-datepicker__nav">
<button class="saga-btn saga-btn--quiet saga-btn--icon saga-btn--sm" aria-label="Previous month"><iconify-icon icon="material-symbols-light:chevron-left-rounded"></iconify-icon></button>
<span class="saga-datepicker__month">October 2026</span>
<button class="saga-btn saga-btn--quiet saga-btn--icon saga-btn--sm" aria-label="Next month"><iconify-icon icon="material-symbols-light:chevron-right-rounded"></iconify-icon></button>
</div>
<div class="saga-datepicker__grid" role="grid">
<span class="saga-datepicker__weekday">Su</span> … <span class="saga-datepicker__weekday">Sa</span>
<button class="saga-datepicker__day saga-datepicker__day--muted">27</button> …
<button class="saga-datepicker__day" aria-selected="true">12</button> …
</div>
</div>
</div>
In the painted picker, dates are entered by picking; the native date input also accepts typing, which is fine for a plain single date.
Time picker#
The time field is a short text input (6em) that accepts typing and also opens
a list on focus (time_picker.png):
- Options every 15 minutes in 12-hour form: "4:30 AM", "4:45 AM".
- When the field is the end of a range, each option carries a right-aligned
annotation with the resulting duration in
--text-xs--color-text-secondary: "30 min", "45 min", "1 hr", "1 hr 15 min". - The selected option is
--color-primarysemibold; the keyboard-active option has a--color-bg-tertiarywash. - Typing "430p" completes to "4:30 PM" on blur. Invalid input turns the border clay and shows an error below.
- Focus is the blue border plus a soft 3px blue halo at 18% opacity, which is the one place the ring sits inside the control instead of outside it.
A time range is two of these with an en dash between them and the DURATION readout to the right.
Date range with quick options#
A reporting range ("Oct 9 – Oct 13, 2026") is a clickable blue label, not a bordered box. It opens a two-column popover: a list of quick options on the left (This week, Last week, Last 30 days, Custom) and a month grid on the right. Picking a quick option applies and closes; clicking two days in the grid sets start and end and applies live. The checked quick option is derived from the applied range, so "Custom" lights up whenever the dates do not match a preset.
Faceted search#
A search box that grows chips (dashboard_rosters_page.png top right, the
sessions search in calendar_view.png):
- The bar is an input box with a magnifier at the left and an italic placeholder that lists what can be searched.
- A "+ Filter" affordance at the right end is a dashed
--radius-fullpill in--color-primarytext. - Choosing a facet adds a chip inside the bar:
--color-primary-lightfill, 1px blue-tinted border,--radius-full,--text-xssemibold,--color-primarytext, with the value and a small ✕. Chips that the page pins (a locked School filter) are navy on grey and have no ✕. - Typing offers suggestions in a popover; each suggestion shows its facet as a tiny uppercase tag.
Validation and errors#
- Validate on blur and on submit, not on every keystroke.
- An invalid control:
--input-border-color-invalidborder, a--color-danger-bgtint is allowed on the time field, and the.saga-errorline below naming what to change: "Enter a time after 4:00 AM". - On submit with errors, focus the first invalid control and keep the submit button enabled; a disabled Save with no explanation is a dead end.
- Form-level failures (the server said no) are a
.saga-notice--dangerat the top of the form, not a toast.
Required, read-only, disabled#
- Required fields are the default and carry no mark. Mark the exceptions
instead: "(optional)" after the label in
--color-text-secondary. If a form is mostly optional, flip it and mark required with an asterisk, but never mix both schemes on one screen. - Read-only keeps the text color and shows
--input-border-color-readonly(#f9f9f9, nearly invisible) so the value reads as text, not as a control. - Disabled is
--input-bg-disabled(#f9f9f9) fill with--input-color-disabled(#444444) text andcursor: not-allowed. Not reduced opacity. The disabled "No Period" field in the Edit Session modal is the reference. A select that is being bypassed (the program selector while "Show Entire District" is on) adds diagonal grey hatching so it reads as "ignored", not merely "off".
Layout#
- Single column by default. Modal forms are 640px wide; a two-column row is for short related fields (start and end, first and last name).
- Control widths follow content: a time is 6em, a date picker its value, a
ratio select the full column (or
.saga-select--inlinein a toolbar). Do not stretch a short control to the column width to make the grid look even. - Labels never sit to the left of controls; the top-label layout is what lets fields line up in rows.
- Group long forms into cards with an
h3title; the Define Tutoring Patterns card indashboard_schedule.pngis the shape.
Do and don't#
Do
- Label every control, above it, in Title Case.
- Keep placeholders italic and beige; they are hints, not labels.
- Choose native or painted by what the list or date needs, not by habit; see "Native or painted?" under Select.
Don't
- Don't let
title=be a control's only name or only tooltip;aria-labelnames it and a painted tooltip describes it. - Don't paint a select or date picker the native control already handles.
- Don't fade disabled controls; fill them.
- Don't mark required fields when they are the majority.
- Don't validate while the user is still typing.