Mobile components

Forms

Form controls collect what a person types, picks, or switches on. On touch every one of them is at least 44px tall, because a finger has no cursor to aim with.

When to use it

Use a text field when the answer is short and free-form: a name, an amount, a search term. Use a select when the answer is one of a known list. Use a switch when the setting takes effect the moment it changes and needs no Save.

Avoid a form where a single tap would do. A screen that asks people to fill in three fields to change one setting is a screen that should have shown three switches.

Touch metrics, same components

The controls are the same .p-field, .p-select, and .p-switch the desktop side documents. A touch class swaps the metrics. The difference is not only size: iOS fields are rows of a grouped card (or filled pills on their own) rather than ringed boxes, and carry SF Pro’s negative tracking at 17px.

ControlPointer defaultTouch
Text field24px tall, 13px, ringed52px row in a grouped card, 17px; 44px filled on its own
Select24px tall, 13px, ringed44px tall, 17px, filled
Switch36 x 16 track64 x 28 track
Segmented control24px track, 6px radius32px track, full capsule
Slider4px track6px track, 38 x 24px thumb

Text fields

iOS presents text fields as the rows of an inset grouped card: no box around each field, just the text, a hairline between rows, and the card around all of them. Put the fields in .p-field-group-touch.

<div class="p-field-group-touch">
<input class="p-field p-field-touch" type="text" placeholder="First name" aria-label="First name" />
<input class="p-field p-field-touch" type="email" placeholder="Email" aria-label="Email" />
</div>
PartValue
Card26px radius, #FFFFFF light, #1C1C1E dark
Row52px tall, 16px side padding, no fill
Separator1px, inset 16px on both sides, between rows only
Font17px, weight 510, tracking -0.43px, #000000 / #FFFFFF
Placeholderrgba(60,60,67,0.30) light, rgba(235,235,245,0.30) dark
Caret--p-sys-blue, 2px wide
Clear button17px xmark.circle.fill in the placeholder color, 44px target

Set the card on the grouped background (#F2F2F7 light, black dark) so it reads as a card. The card clips its rows, so the first and last rows need no radius of their own.

Rows carry no label of their own on iOS, so give each field an aria-label, or a visible .p-label above the card that names the group.

The clear button

While a field has focus and a value, iOS shows a clear button at the trailing edge. Wrap the field in .p-field-touch-row and add a .p-field-touch-clear button after it. The CSS shows and hides the button; clearing the value takes one line of script, since the button is an ordinary button:

clear.addEventListener('click', () => { field.value = ''; field.focus(); });

A type="search" field needs no extra markup: the browser’s own cancel button takes the same glyph.

Focus

iOS marks the focused row with the caret alone. On the web a caret is easy to lose, so the focused row also shows a 3px accent bar on its leading edge, drawn inside the row so the card never clips it.

Standalone filled field

Outside a grouped card, a field is the filled form: a 44px pill with its own background. Add .p-field-touch-filled to ask for it explicitly, including inside a card. A .p-field-touch placed outside a card gets this look without the modifier.

<label class="p-label" for="name">Full name</label>
<input class="p-field p-field-touch p-field-touch-filled" id="name" type="text" placeholder="Jane Appleseed" />
PartValue
Height44px, the tap target and the field are the same box
Radius10px
Padding0 16px
Font17px, weight 510, tracking -0.43px
Fillrgba(118,118,128,0.12) light, rgba(118,118,128,0.24) dark
Focus4px ring in --p-field-ring-focus-outer

The fill is built on #767680, not on pure black. iOS runs a separate fill scale from macOS, so the touch controls read from --p-field-touch-fill rather than the --p-fill-* tokens.

There is no border. A ring plus a fill reads as two boxes. The focus state supplies the outline when it is needed.

Always pair a standalone field with a .p-label. A placeholder is not a label: it disappears the moment someone types, and people who tab back to check what a field wanted find nothing there.

Set the right type and inputmode. type="email" brings up a keyboard with an @ key, inputmode="numeric" brings up digits, and type="search" gives the keyboard a Search key instead of Return. This is the cheapest usability win on any mobile form.

<input class="p-field p-field-touch" type="email" inputmode="email"
       autocomplete="email" placeholder="jane@example.com" />

Add autocomplete. It lets the browser fill a name, an address, or a one-time code without anyone typing, and on a phone that is the difference between a form people finish and a form they abandon.

Selects

<div class="p-select p-select-touch">
<select aria-label="Sort order">
  <option>Newest first</option>
  <option>Oldest first</option>
  <option>Name</option>
</select>
</div>

.p-select-touch gives the select the same 44px box, 10px radius, and filled background as a text field, with 36px of right padding so the label never runs under the chevron. The chevron sits 14px from the right edge at 12 x 14px.

It is a real <select>, so the tap opens the platform’s own picker: the scroll wheel on iOS, the native list elsewhere. That picker already handles momentum scrolling, keyboard input, VoiceOver, and Dynamic Type. Building a custom dropdown means reimplementing all of it.

Switches

<label class="p-switch p-switch-touch">
<input type="checkbox" checked />
<span></span>
</label>
PartValue
Track64 x 28px, full capsule
Knob38 x 24px, 2px inset, travels 22px
Pressed60 x 40px clear glass lens, 9px past the track end
On--p-sys-green, #34C759 light and #30D158 dark
Offrgba(60,60,67,0.30) light, rgba(235,235,245,0.30) dark
Target44px, the label carries the extra height

The knob is a wide lozenge, not a circle. It is 38px across on a 64px track, so it fills most of the control and leaves 22px of travel.

Switches default to green. Add any .p-sys-* class to retint one, and the green steps aside. Consider keeping green unless the switch controls something the accent color already stands for.

A switch takes effect immediately. If the change needs a Save button, it is not a switch, it is a checkbox in a form.

The morph

Touching a switch lifts the knob into a Liquid Glass lens. The 38 x 24 knob grows to 60 x 40, centered where it sat, so it reaches 9px past the end of the track. The track is cut out under the glass and redrawn inside it, magnified, so its color bends at the rim. Drag across the middle and the knob follows your finger to that side; lift your finger and it settles. A press that moves less than 6px counts as a tap. This needs liquid_glass.js:

<script type="module" src="/js/liquid_glass.js"></script>

Without the script, a CSS-only morph grows the knob into a clear lens, travels it, and sets it down.

For the flatter iOS 27 look, add data-p-lens="clear" to the label: the track shows through unmagnified, and the glass gets a 9% white body, a hairline edge, thin lines inside its top and bottom edges tinted with the track color, and a soft shadow about 7px below.

A disabled switch fades as a whole to 50%, knob included.

This is the same animation the desktop switch uses, scaled by tokens rather than rewritten. The geometry is --p-switch-track-w, --p-switch-track-h, --p-switch-knob-w, and --p-switch-knob-inset; travel is derived from them, so a size variant cannot drift out of alignment. Browsers without @property fall back to a plain slide and the markup is identical.

State without color

<label class="p-switch p-switch-touch p-switch-labels">
<input type="checkbox" checked />
<span></span>
</label>

On and off differ by color and by knob position, and at a glance the color is doing most of the work. Green against gray is exactly the comparison that green-weak vision flattens. .p-switch-labels draws a bar on the track when the switch is on and a ring when it is off, so the state survives without color.

The glyphs are opt-in only. iOS draws them for its own On/Off Labels setting, which is separate from Increase Contrast, so Puppertino does not attach them to a contrast preference and quietly restyle every switch on the page. Consider adding the class outright on switches that control something consequential.

Segmented controls

<div class="p-segmented p-segmented-touch" role="group" aria-label="View">
<label class="p-segment"><input type="radio" name="view" checked /><span>Day</span></label>
<label class="p-segment"><input type="radio" name="view" /><span>Week</span></label>
<label class="p-segment"><input type="radio" name="view" /><span>Month</span></label>
</div>
PartValue
Track32px tall, 16px radius, rgba(118,118,128,0.12) light and 0.24 dark
Pill28px tall, 14px radius, inset 2px, 4px between segments
Pill fill#FFFFFF light, rgba(255,255,255,0.27) dark
Pill shadow0 2px 20px rgba(0,0,0,0.06)
SeparatorsNone
Label13px, tracking -0.08px, weight 590 selected and 510 otherwise, black (white in dark)
Large.p-segmented-touch-large, 36px track holding a 32px pill
Extra large.p-segmented-touch-xlarge, 50px track holding a 46px pill
DisabledThe whole control at 50%

The pill is white, so the selected label keeps its own color instead of flipping to white the way the macOS control does. There are no separators between segments; the pill and its soft shadow carry the selection on their own.

Those three numbers, a 2px inset and a 4px gap on a 32px track, reproduce iOS pill widths at every segment count. On a 430px track it gives 211px at two segments, 139.33px at three, 103.5px at four, and 82px at five. The pill width is computed from the segment count rather than tabulated, so it stays exact at any track width.

It is still a radio group. Arrow keys move the selection, form submission works, and no JavaScript is involved. The pill slides between segments where :has() is available and crossfades where it is not.

Five segments is the practical ceiling on a phone. Below about 60px a label truncates and the target gets hard to hit.

Sliders

<input class="p-slider p-slider-touch" type="range"
     min="0" max="100" value="60"
     style="--p-slider-fill: 60%;" aria-label="Volume" />
PartValue
Track6px tall, 3px radius
Track fillrgba(120,120,120,0.20) light, rgba(120,120,128,0.36) dark
Filled portion#0088FF light, #0091FF dark
Thumb38 x 24px white capsule, 0 6px 13px rgba(0,0,0,0.12), 0 0.5px 4px rgba(0,0,0,0.12)
Ticks.p-slider-ticks: 4px dots in rgba(60,60,67,0.18), 9px below the track center
Dragged58 x 38px glass lens
DisabledThe whole control at 50%; the fill stays blue
Focus4px accent ring on the thumb

The 6px track is thicker than the pointer slider’s. The thumb sits inside the track ends, and the fill stops under its center. Set the number of tick intervals with --p-slider-ticks; the first and last dots sit 18px in from the ends, under the thumb at the minimum and maximum.

With liquid_glass.js loaded, dragging turns the thumb into a Liquid Glass lens. The 38 x 24 capsule grows to 58 x 38 and turns to glass: the track shows through at its real size and in focus, and the fill ends in a rounded cap at the middle of the lens, dimming slightly and flaring along the rim where it enters the glass. In dark mode the glass has a dark hairline with thin bright lines inside its top and bottom edges. It settles back into a solid thumb 350ms after release. Add data-p-lens="clear" to the input for the flatter iOS 27 look: a 60 x 40 glass with the unmodified track underneath and a soft ring shadow about 7px below.

Firefox paints the filled portion natively through ::-moz-range-progress. WebKit has no equivalent pseudo-element, so the fill is a gradient sized by --p-slider-fill.

sliders.js publishes that variable on every .p-slider, so loading it once fills both the pointer and the touch slider. The control works without it; the track just reads unfilled in Safari and Chrome.

<script type="module" src="/js/sliders.js"></script>

Always give a slider an aria-label or a visible label. A bare track announces as “slider” and nothing else.

.p-progress-touch is a 4px bar with a 2px radius, in the slider’s track and fill colors.

<progress class="p-progress p-progress-touch" value="0.6" max="1"></progress>

The activity indicator is .p-spinner-touch, added to .p-spinner: eight spokes in the secondary label color, rgba(60,60,67,0.6) in light and rgba(235,235,245,0.7) in dark, fading around the circle. The default is 20px; .p-spinner-touch-small is 14px and .p-spinner-touch-large is 35px. Each size has its own spoke proportions, so scale by class rather than by width.

<span class="p-spinner p-spinner-touch" role="progressbar" aria-label="Loading"></span>

Reduced motion replaces the rotation with a slow pulse.

Page controls

<nav class="p-page-control" aria-label="Page">
<a class="p-page-dot" href="#page-1" aria-current="true"><span>Page 1</span></a>
<a class="p-page-dot" href="#page-2"><span>Page 2</span></a>
<a class="p-page-dot" href="#page-3"><span>Page 3</span></a>
</nav>

Dots are 8px on a 16px pitch. The current one is pure #000000, the rest the tertiary label rgba(60,60,67,0.30); in dark mode #FFFFFF and rgba(235,235,245,0.30). aria-current carries the state, the same as the tab bar.

The dots sit on a glass platter: a 24px capsule, 12px of padding past the first and last dot, a 20px backdrop blur and an almost clear tint (white 10% in light, black 2% in dark). No rim, no shadow. It is the thinnest glass in the system, there so the dots stay legible wherever a paged set lands on a photo or a colored page. .p-page-control-plain removes it for the bare dots. Reduced transparency swaps the blur for an opaque capsule.

An 8px dot is nowhere near a tap target. The row is 44px tall, and each dot extends its own target to the full row height and to exactly the 16px pitch across, through an absolutely positioned pseudo-element. The dots stay 8px, the layout does not move, and neighbouring targets never overlap and steal each other’s taps.

That still leaves a 16px-wide target, under the 44px guidance. Dots indicate position first and accept taps second, which is what iOS does; swiping the content is the primary way through a paged set. Consider not making the dots the only way to move between pages.

Use .p-page-control-light over a photo, where the page content sets the contrast rather than the page background.

Each dot holds a visually clipped label. It stays in the accessibility tree, so the control announces as a list of pages rather than a row of unlabelled links.

For long sets iOS shrinks the dots nearest the ends instead of wrapping. page_controls.js applies .p-page-dot-sm (6px) and .p-page-dot-xs (4px) to the right dots as the current page moves, and keeps a set of seven or fewer at full size.

Point the control at a scrolling pager and the two stay in step, in both directions:

<div class="p-pager" id="gallery">…</div>
<nav class="p-page-control" data-p-pages="#gallery" aria-label="Page">…</nav>
<script type="module" src="/js/page_controls.js"></script>

Without data-p-pages the dots still shrink and still respond to taps; only the scroll sync is skipped.

Steppers

<div class="p-stepper-field p-stepper-field-touch">
<input class="p-field p-field-touch" type="number" value="2" aria-label="Guests" />
<span class="p-stepper p-stepper-touch">
  <button type="button" data-p-step="down" aria-label="Decrease"></button>
  <button type="button" data-p-step="up" aria-label="Increase"></button>
</span>
</div>
PartValue
Control92 x 32px, full capsule at 16px radius
Half46 x 32px each, the tap target and the shape are the same box
Fillrgba(118,118,128,0.12) light, rgba(118,118,128,0.24) dark, the same gray as the touch fields and buttons
Pressedrgba(0,0,0,0.08) over the half being held
Divider1 x 24px, rgba(60,60,67,0.30), 4px inset
GlyphsMinus and plus at 14px, not chevrons

iOS lays the stepper out horizontally, where the pointer version stacks two chevrons in a 20 x 24 box. It is minus and plus rather than down and up, which is the same operation described the way a phone describes it.

The stepper holds no value of its own. Pair it with a narrow field, which is what .p-stepper-field already does for the pointer size. .p-stepper-field-touch widens the gap to 12px and sizes the field at 72px, enough for a few digits, with the value centered.

The native number spinners are suppressed, since the stepper already does that job. Without this, the field shows its own arrows next to the stepper’s.

Each half is 46px wide and 32px tall. That clears 44px across but not down, so a stepper standing alone in a row wants the row to supply the height. Inside a .p-list-touch row it gets 52px for free.

Wire the buttons up yourself, or use steppers.js. The CSS carries no behavior.

Pop-up buttons

<div class="p-popup-touch">
<select aria-label="Sort by">
  <option>Newest</option>
  <option>Oldest</option>
</select>
</div>
PartValue
Label17px, weight 590, in the accent color
ChevronUp and down, 11 x 15px, 5px after the label, same color
FillNone, and no border
Height44px, the tap target
DisabledLabel and chevron in the tertiary label color, rgba(60,60,67,0.30)
PressedLabel and chevron dim to 60%

A pop-up button shows the current choice and opens a menu of the others. On iOS it has no background at all: the accent-colored value and its chevron are the whole control. Use it inline in a sentence-like row, in a toolbar, or at the trailing edge of a list row.

.p-popup-touch wraps a real <select>, so a tap opens the platform’s own picker. Tint it with a .p-sys-* class on the wrapper. Give the select an aria-label when the value alone does not say what it chooses.

Use .p-select-touch instead when the choice is one field among many in a form, where a filled box lines up with the text fields around it.

Labels

Notifications Allow alerts while the app is closed.
<div style="display: grid; gap: 4px; width: 280px;">
<span class="p-label">Notifications</span>
<span class="p-sublabel">Allow alerts while the app is closed.</span>
</div>

.p-label is 13px at weight 510 in --p-text-primary. .p-sublabel is the same size in --p-text-secondary and carries the explanation. Put the why in the sublabel, not in a tooltip. There is no hover on a phone, so a tooltip is unreachable.

States

Focus on a row in a grouped card draws a 3px accent bar at its leading edge. On a standalone field it draws a 4px ring in --p-field-ring-focus-outer, wider than the 3.5px pointer ring so it stays visible next to a filled background. It appears for keyboard and switch control navigation. Never remove it.

Disabled turns a row’s text to the placeholder color, drops standalone fields to --p-field-bg-disabled with placeholder-colored text, and fades switches to 50%. The cursor becomes not-allowed. Consider hiding a control people cannot use rather than disabling it, unless the disabled state itself is the information.

Invalid is not styled automatically. The v1 .p-form-text classes tied red borders to :invalid, which fires while someone is still typing. Add your own class on blur instead.

Dark mode

The grouped card moves from #FFFFFF to #1C1C1E and its separators to white at 10%. Field and select fills move from 12% to 24%. The switch off-track flips from rgba(60,60,67,0.30) to rgba(235,235,245,0.30), and green shifts from #34C759 to #30D158. Text and placeholder colors follow their tokens.

Put .p-auto-dark-mode on the body to follow the system, or .p-dark-mode to pin it.

Accessibility

Every field needs a label associated by for and id, or wrapped around the input. A .p-label that only sits nearby is a visual label, not an accessible one.

A switch is a real <input type="checkbox"> inside a <label>, so it is reachable by keyboard, toggles with Space, and announces its checked state without any ARIA. Do not replace it with a <div role="switch">.

Reduced motion removes the field transitions and the switch morph. The switch still moves between states, it just arrives without the lens.

Increased contrast turns on the switch glyphs described above.

What is not here yet

Combo boxes and pull-down buttons ship at pointer sizes only. See desktop forms for what those controls do today.

For choosing one option from a list on touch, there are three shapes and they suit different situations:

UseWhen
.p-select-touchThe list is plain text and any length, inside a form. Opens the platform’s own picker.
.p-popup-touchThe same, as a borderless accent value in a row or toolbar.
Action sheetOptions need icons, subtitles, or a destructive one set apart.
MenuThe control stays on screen and the list is short.

Checkboxes and radios are also pointer-sized. iOS expresses the same choices through lists: a row with a checkmark for a checkbox, and a group of rows with one checkmark for a radio group.

Migration from v1

v1v2
.p-form-text, .p-form-text-alt.p-field + .p-field-touch
.p-form-select.p-select + .p-select-touch
.p-form-switch.p-switch + .p-switch-touch
.p-form-label.p-label
.p-form-no-validatenot needed, v2 does not style :invalid

The v1 classes still work through 2.x. The v2 controls need no wrapper for text fields and no JavaScript anywhere.

Copied