Formularios
Lección 27 de 37 · 5 min
Los controles nativos ya traen teclado, foco y lectores de pantalla resueltos. Con unas pocas propiedades se ven como parte de tu diseño, avisan los errores en el momento justo y se adaptan a lo que escribe cada persona.
Color de acento y del cursor
accent-color le da un color a las partes marcadas de los controles nativos: el tilde del checkbox, el punto del radio, el relleno del range y la barra de progress. El navegador elige solo el color del tilde para que contraste. caret-color pinta el cursor que parpadea en los campos de texto.
Las dos se heredan, así que alcanza con ponerlas una vez en el formulario o en :root.
form {accent-color: oklch(0.56 0.13 155);caret-color: oklch(0.56 0.13 155);}
caret-color es Baseline desde hace años. accent-color todavía no figura como Baseline: está desde Chrome 93 y Firefox 92, y Safari lo soporta del todo recién desde la 26.2 (antes, en parte). Donde no llega, el control queda con su color de siempre, así que se puede usar sin miedo.
Un detalle: si le ponés borde o fondo propio a un control, algunos navegadores (Chrome, por ejemplo) dejan de dibujarlo nativo y accent-color ya no se aplica. Un reset global de bordes, como el border: 0 solid que trae Tailwind, alcanza para que un progress se vuelva una barra verde plana; border: revert en ese control lo devuelve.
Rediseñar un control
Cuando un color no alcanza, appearance: none le saca al control el dibujo del sistema y lo deja como una caja vacía que estilás como cualquier otra. Sigue siendo un input de verdad: se marca con Espacio, se envía con el formulario y lo anuncian los lectores de pantalla. El tilde se dibuja con un ::before y :checked cambia el estado.
Cambiá el color y activá el checkbox dibujado: el resto de los controles sigue leyendo accent-color, y el checkbox nuevo usa el mismo valor.
.pedido {accent-color: oklch(0.56 0.13 155);caret-color: oklch(0.56 0.13 155);}
<form class="accent-[oklch(0.56_0.13_155)] caret-[oklch(0.56_0.13_155)]"><label><input type="checkbox"> Repetir cada 15 días</label>…</form>
Si redibujás un control, el foco queda a tu cargo: sumale un :focus-visible con outline. El botón de los campos de archivo tiene su propio pseudoelemento, ::file-selector-button, Baseline desde 2021:
input[type="file"]::file-selector-button {border: 0;border-radius: 6px;padding: 6px 12px;background: oklch(0.95 0 0);font: inherit;}
Errores en el momento justo
:invalid coincide con cualquier campo que no pasa la validación del HTML (required, type="email", pattern). El problema es que coincide desde que carga la página: un formulario vacío aparece lleno de errores antes de que escribas nada.
El parche clásico fue sumar :not(:placeholder-shown): mientras se ve el placeholder, el campo está vacío y no se marca. Pero apenas escribís la primera letra de un email ya es inválido, y se pone rojo mientras todavía estás escribiendo.
:user-invalid resuelve eso: coincide recién cuando la persona cambió el campo y salió de él, o cuando intentó enviar el formulario. Su par es :user-valid. Las dos son Baseline ampliamente disponible.
Probá las tres opciones: escribí la mitad de un email, pasá al otro campo con Tab y apretá Continuar con los campos vacíos. Al lado de cada campo ves qué pseudoclases coinciden.
.envio .error {display: none;}.envio input:user-invalid {border-color: oklch(0.58 0.2 27);}.envio input:user-invalid + .error {display: block;}
<label for="email">Email</label><input id="email" type="email" requiredclass="peer border user-invalid:border-[oklch(0.58_0.2_27)]"><p class="hidden peer-user-invalid:block">Falta la @ o el dominio.</p>
El mensaje de error está siempre en el HTML y el CSS solo lo muestra. Conectalo al campo con aria-describedby para que el lector de pantalla lo lea junto con la etiqueta.
Campos autocompletados
Cuando el navegador completa un campo con datos guardados, el campo coincide con :autofill y le pinta un fondo propio (amarillo o celeste) con mucha prioridad. No se puede pisar con background; el truco de siempre es taparlo con una sombra interior del tamaño del campo.
input:-webkit-autofill,input:autofill {box-shadow: inset 0 0 0 100px oklch(0.97 0.02 155);-webkit-text-fill-color: oklch(0.25 0 0);}
:autofill sin prefijo está desde Chrome 110 y Safari 15, y Firefox lo soporta en parte; por eso conviene escribir también :-webkit-autofill, que entienden todos. No es Baseline.
Campos que crecen
Un textarea tiene un alto fijo y un input un ancho fijo, escribas lo que escribas. Con field-sizing: content se miden por su contenido, como un elemento cualquiera: el textarea crece con cada línea y el input con cada letra. Antes esto pedía JavaScript.
Ponele límites: min-height para que vacío no quede de una línea y max-height para que, pasado el tope, aparezca el scroll. La unidad lh mide en alturas de línea, justo lo que hace falta acá.
Datos para la entrega
.entrega textarea,.entrega input {field-sizing: content;}.entrega textarea {min-height: 2lh;max-height: 5lh;}.entrega input {min-width: 3ch;max-width: 100%;}
<input class="field-sizing-content min-w-[3ch] max-w-full" placeholder="4B"><textarea class="field-sizing-content min-h-[2lh] max-h-[5lh]">…</textarea>
field-sizing es Baseline desde junio de 2026 (Chrome 123, Firefox 152, Safari 26.2). En un navegador anterior el campo simplemente queda fijo, así que no rompe nada.
Un select a tu medida
El select nativo siempre fue el control más difícil de estilar: la lista desplegable la dibuja el sistema. Con appearance: base-select (en el select y en su lista, ::picker(select)) pasa a ser HTML común: el botón, la lista y cada option aceptan CSS. Aparecen pseudoelementos nuevos como ::picker-icon (la flecha) y ::checkmark (el tilde de la opción elegida), y :open coincide mientras la lista está abierta.
Entrega el jueves 16
Te avisamos por email 30 minutos antes.
/* sin soporte, el bloque no aplica y queda el select nativo */@supports (appearance: base-select) {.horario select,.horario ::picker(select) {appearance: base-select;}.horario ::picker(select) {border: 1px solid oklch(0.5 0 0 / 25%);border-radius: 10px;padding: 4px;box-shadow: 0 8px 24px oklch(0 0 0 / 12%);}.horario option {border-radius: 6px;}.horario option:checked {font-weight: 500;}.horario select::picker-icon {transition: rotate 150ms cubic-bezier(0.22, 1, 0.36, 1);}.horario select:open::picker-icon {rotate: 180deg;}}
<!-- sin soporte, el bloque no aplica y queda el select nativo --><select class="[appearance:base-select] [&::picker(select)]:[appearance:base-select][&::picker(select)]:border [&::picker(select)]:border-[oklch(0.5_0_0/25%)][&::picker(select)]:rounded-[10px] [&::picker(select)]:p-1[&::picker(select)]:shadow-[0_8px_24px_oklch(0_0_0/12%)][&_option]:rounded-[6px] [&_option:checked]:font-medium[&::picker-icon]:transition-[rotate] [&::picker-icon]:duration-150 open:[&::picker-icon]:rotate-180"><option>9 a 12 h · mañana</option><option>12 a 15 h · mediodía</option><option>15 a 18 h · tarde</option><option>18 a 21 h · noche</option></select>
Todavía no es Baseline: está en Chrome 135 y Safari 27, y en Firefox solo detrás de una opción experimental. Envolvé todo en @supports (appearance: base-select): donde no hay soporte, queda el select nativo, que funciona igual.
En Tailwind
Tailwind tiene utilidades o variantes para casi todo lo de esta lección:
| CSS | Tailwind |
|---|---|
| accent-color · caret-color | accent-green-600 · caret-green-600 |
| appearance: none | appearance-none |
| :checked · ::file-selector-button | checked: · file: |
| :user-invalid · :user-valid | user-invalid: · user-valid: |
| :placeholder-shown · :autofill | placeholder-shown: · autofill: |
| mensaje al lado del campo | peer-user-invalid: |
| field-sizing: content | field-sizing-content |
| max-height: 5lh | max-h-[5lh] |
| appearance: base-select | [appearance:base-select] |
<input type="email" requiredclass="peer border user-invalid:border-red-600user-valid:border-green-600"><p class="hidden peer-user-invalid:block">Falta la @ o el dominio.</p><textarea class="field-sizing-content min-h-[2lh] max-h-[5lh]"></textarea><form class="accent-green-600 caret-green-600">…</form>
Forms
Lesson 27 of 37 · 5 min
Native controls come with keyboard, focus and screen reader support already built in. A few properties make them fit your design, show errors at the right moment and adapt to what each person types.
Accent and caret color
accent-color colors the marked parts of native controls: the checkbox tick, the radio dot, the filled part of a range and the progress bar. The browser picks the tick's color itself so it contrasts. caret-color paints the blinking cursor in text fields.
Both are inherited, so setting them once on the form or on :root is enough.
form {accent-color: oklch(0.56 0.13 155);caret-color: oklch(0.56 0.13 155);}
caret-color has been Baseline for years. accent-color isn't listed as Baseline yet: it shipped in Chrome 93 and Firefox 92, and Safari only supports it fully since 26.2 (partially before that). Where it's missing, the control keeps its usual color, so it's safe to use.
One catch: once you give a control your own border or background, some browsers (Chrome, for one) stop drawing it natively and accent-color no longer applies. A global border reset, like the border: 0 solid Tailwind ships, is enough to turn a progress into a flat green bar; border: revert on that control brings it back.
Redesigning a control
When a color isn't enough, appearance: none removes the system drawing and leaves an empty box you style like any other. It's still a real input: Space toggles it, it's sent with the form and screen readers announce it. The tick is drawn with ::before and :checked switches the state.
Change the color and turn on the drawn checkbox: the other controls keep reading accent-color, and the new checkbox uses the same value.
.order {accent-color: oklch(0.56 0.13 155);caret-color: oklch(0.56 0.13 155);}
<form class="accent-[oklch(0.56_0.13_155)] caret-[oklch(0.56_0.13_155)]"><label><input type="checkbox"> Repeat every 2 weeks</label>…</form>
Once you redraw a control, focus is your job: add a :focus-visible with an outline. The button in file inputs has its own pseudo-element, ::file-selector-button, Baseline since 2021:
input[type="file"]::file-selector-button {border: 0;border-radius: 6px;padding: 6px 12px;background: oklch(0.95 0 0);font: inherit;}
Errors at the right moment
:invalid matches any field that fails HTML validation (required, type="email", pattern). The catch is that it matches as soon as the page loads: an empty form shows up full of errors before you type anything.
The classic patch was adding :not(:placeholder-shown): while the placeholder shows, the field is empty and stays unmarked. But the first letter of an email is already invalid, so the field turns red while you're still typing.
:user-invalid fixes that: it only matches once the person changed the field and left it, or tried to submit the form. Its pair is :user-valid. Both are Baseline widely available.
Try the three options: type half an email, Tab to the next field, and press Continue with empty fields. Next to each field you can see which pseudo-classes match.
.shipping .error {display: none;}.shipping input:user-invalid {border-color: oklch(0.58 0.2 27);}.shipping input:user-invalid + .error {display: block;}
<label for="email">Email</label><input id="email" type="email" requiredclass="peer border user-invalid:border-[oklch(0.58_0.2_27)]"><p class="hidden peer-user-invalid:block">The @ or the domain is missing.</p>
The error message is always in the HTML and CSS only reveals it. Link it to the field with aria-describedby so screen readers read it along with the label.
Autofilled fields
When the browser fills a field with saved data, the field matches :autofill and gets the browser's own background (yellow or light blue) with high priority. background can't override it; the usual trick is covering it with an inset shadow as large as the field.
input:-webkit-autofill,input:autofill {box-shadow: inset 0 0 0 100px oklch(0.97 0.02 155);-webkit-text-fill-color: oklch(0.25 0 0);}
Unprefixed :autofill is in Chrome 110 and Safari 15, and Firefox supports it partially; that's why it's worth also writing :-webkit-autofill, which all of them understand. It isn't Baseline.
Fields that grow
A textarea has a fixed height and an input a fixed width, whatever you type. With field-sizing: content they size to their content like any other element: the textarea grows with each line and the input with each letter. This used to take JavaScript.
Give them limits: min-height so an empty one isn't a single line, and max-height so it scrolls past a cap. The lh unit measures in line heights, which is exactly what you want here.
Delivery details
.delivery textarea,.delivery input {field-sizing: content;}.delivery textarea {min-height: 2lh;max-height: 5lh;}.delivery input {min-width: 3ch;max-width: 100%;}
<input class="field-sizing-content min-w-[3ch] max-w-full" placeholder="4B"><textarea class="field-sizing-content min-h-[2lh] max-h-[5lh]">…</textarea>
field-sizing has been Baseline since June 2026 (Chrome 123, Firefox 152, Safari 26.2). In an older browser the field just stays fixed, so nothing breaks.
A select of your own
The native select was always the hardest control to style: the system draws its dropdown. With appearance: base-select (on the select and on its list, ::picker(select)) it becomes regular HTML: the button, the list and each option take CSS. New pseudo-elements show up, like ::picker-icon (the arrow) and ::checkmark (the tick on the chosen option), and :open matches while the list is open.
Delivery on Thursday the 16th
We'll email you 30 minutes before.
/* without support the block doesn't apply and the native select stays */@supports (appearance: base-select) {.time-window select,.time-window ::picker(select) {appearance: base-select;}.time-window ::picker(select) {border: 1px solid oklch(0.5 0 0 / 25%);border-radius: 10px;padding: 4px;box-shadow: 0 8px 24px oklch(0 0 0 / 12%);}.time-window option {border-radius: 6px;}.time-window option:checked {font-weight: 500;}.time-window select::picker-icon {transition: rotate 150ms cubic-bezier(0.22, 1, 0.36, 1);}.time-window select:open::picker-icon {rotate: 180deg;}}
<!-- without support the block doesn't apply and the native select stays --><select class="[appearance:base-select] [&::picker(select)]:[appearance:base-select][&::picker(select)]:border [&::picker(select)]:border-[oklch(0.5_0_0/25%)][&::picker(select)]:rounded-[10px] [&::picker(select)]:p-1[&::picker(select)]:shadow-[0_8px_24px_oklch(0_0_0/12%)][&_option]:rounded-[6px] [&_option:checked]:font-medium[&::picker-icon]:transition-[rotate] [&::picker-icon]:duration-150 open:[&::picker-icon]:rotate-180"><option>9 am – 12 pm · morning</option><option>12 – 3 pm · midday</option><option>3 – 6 pm · afternoon</option><option>6 – 9 pm · evening</option></select>
It's not Baseline yet: it's in Chrome 135 and Safari 27, and in Firefox only behind an experimental flag. Wrap it all in @supports (appearance: base-select): without support you get the native select, which works just the same.
In Tailwind
Tailwind has utilities or variants for almost everything in this lesson:
| CSS | Tailwind |
|---|---|
| accent-color · caret-color | accent-green-600 · caret-green-600 |
| appearance: none | appearance-none |
| :checked · ::file-selector-button | checked: · file: |
| :user-invalid · :user-valid | user-invalid: · user-valid: |
| :placeholder-shown · :autofill | placeholder-shown: · autofill: |
| message next to the field | peer-user-invalid: |
| field-sizing: content | field-sizing-content |
| max-height: 5lh | max-h-[5lh] |
| appearance: base-select | [appearance:base-select] |
<input type="email" requiredclass="peer border user-invalid:border-red-600user-valid:border-green-600"><p class="hidden peer-user-invalid:block">The @ or the domain is missing.</p><textarea class="field-sizing-content min-h-[2lh] max-h-[5lh]"></textarea><form class="accent-green-600 caret-green-600">…</form>