CSS Login Forms: 16 Examples You Can Copy and Customize

Most CSS login forms cover the resting state and stop there. A mistyped email or a Tab press gets whatever the browser defaults to, even though :user-invalid, focus rings and custom properties handle those cases in plain CSS.

Below are 16 CSS login form examples you can try and copy, followed by a guide to markup, states, dark mode and WCAG 2.2.

By Bogdan Sandu · Updated October 26, 2026

Login page layouts

Three ways to place the form on the page: a centered card, a split screen with a photo, and a sign-in dialog over an app. Every form here cancels its submit event, so you can type freely and nothing leaves the page.

Centered card with stacked labels

  • flexbox centering
  • labels above inputs
  • autocomplete tokens

Sign in to Quillstack

Welcome back. Your drafts are where you left them.

New to Quillstack? Create an account

The baseline every other example builds on. A flex wrapper centers the card on both axes, the card is width: 100% with a max-width of 22rem, and each label sits above its input in a flex column. On a full page, give the wrapper min-height: 100dvh instead of the fixed height used here.

View code
.lg-center {
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  min-height: 34rem;
  box-sizing: border-box;
}
.lg-center__card {
  display: flex;
  flex-direction: column;
  gap: 1rem;
  width: 100%;
  max-width: 22rem;
  box-sizing: border-box;
  padding: 2rem 1.75rem 1.75rem;
  border-radius: 16px;
  background: #fff;
  color: #1d2233;
  box-shadow: 0 1px 2px rgba(17, 24, 39, .06), 0 14px 36px rgba(17, 24, 39, .09);
}
.lg-center__mark {
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  border-radius: 11px;
  background: #3b4cca;
  color: #fff;
  font-family: "Fraunces", serif;
  font-size: 1.3rem;
  font-weight: 700;
}
.lg-center__title {
  margin: 0;
  font-size: 1.375rem;
  font-weight: 700;
  line-height: 1.25;
}
.lg-center__sub {
  margin: -.5rem 0 .25rem;
  color: #586074;
  font-size: .9375rem;
  line-height: 1.45;
}
.lg-center__field {
  display: flex;
  flex-direction: column;
  gap: .4rem;
}
.lg-center__label {
  font-size: .875rem;
  font-weight: 600;
}
.lg-center .lg-center__input {
  width: 100%;
  min-height: 46px;
  box-sizing: border-box;
  padding: .65rem .85rem;
  border: 1px solid #848b9d;
  border-radius: 10px;
  background: #fff;
  color: #1d2233;
  font: inherit;
  font-size: 1rem;
}
.lg-center .lg-center__input:focus-visible {
  border-color: #3b4cca;
  outline: 2px solid #3b4cca;
  outline-offset: 2px;
}
.lg-center .lg-center__btn {
  min-height: 46px;
  margin-top: .25rem;
  border: 0;
  border-radius: 10px;
  background: #3b4cca;
  color: #fff;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
  transition: background-color .15s ease;
}
.lg-center .lg-center__btn:hover {
  background: #2c3aa3;
}
.lg-center .lg-center__btn:focus-visible {
  outline: 3px solid #3b4cca;
  outline-offset: 3px;
}
.lg-center__status {
  margin: -.25rem 0 0;
  color: #1f6f43;
  font-size: .875rem;
}
.lg-center__status:empty {
  display: none;
}
.lg-center__alt {
  margin: 0;
  color: #586074;
  font-size: .9375rem;
  text-align: center;
}
.lg-center__link {
  display: inline-block;
  padding: .2rem 0;
  color: #3b4cca;
  font-weight: 600;
}
.lg-center__link:focus-visible {
  outline: 2px solid #3b4cca;
  outline-offset: 2px;
  border-radius: 3px;
}

Split screen with a photo panel

  • CSS grid
  • container query
  • object-fit
A waterfall dropping into a mossy green gorge surrounded by pine forest
Trailmark Shared route plans for small hiking groups.

Log in to plan your next trip

Forgot it?

A two-column grid holds the photo and the form. A container query on the wrapper, not a viewport media query, decides the layout, so the photo turns into a short banner whenever the component itself is narrow. The caption sits on a solid translucent panel, so its contrast does not depend on the photo.

View code
.lg-split {
  container-type: inline-size;
  width: 100%;
  background: #f5f2ea;
  color: #1c2b22;
}
.lg-split__inner {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
}
.lg-split__photo {
  position: relative;
  height: 9rem;
  margin: 0;
  overflow: hidden;
}
.lg-split__img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.lg-split__caption {
  position: absolute;
  left: .75rem;
  bottom: .75rem;
  display: flex;
  flex-direction: column;
  gap: .1rem;
  max-width: calc(100% - 1.5rem);
  padding: .5rem .75rem;
  border-radius: 8px;
  background: rgba(12, 32, 22, .78);
  color: #fff;
}
.lg-split__brand {
  font-family: "Fraunces", serif;
  font-size: 1.1rem;
  font-weight: 700;
}
.lg-split__tag {
  display: none;
  font-size: .875rem;
}
.lg-split__form {
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 1.1rem;
  padding: 2rem 1.25rem 2.25rem;
}
.lg-split__title {
  margin: 0;
  font-family: "Fraunces", serif;
  font-size: 1.6rem;
  font-weight: 600;
  line-height: 1.2;
}
.lg-split__field {
  display: flex;
  flex-direction: column;
  gap: .4rem;
}
.lg-split__row {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 1rem;
}
.lg-split__label {
  font-size: .9rem;
  font-weight: 600;
}
.lg-split__link {
  display: inline-block;
  padding: .15rem 0;
  color: #1f5f45;
  font-size: .875rem;
  font-weight: 600;
}
.lg-split .lg-split__input {
  width: 100%;
  min-height: 48px;
  box-sizing: border-box;
  padding: .7rem .9rem;
  border: 1px solid #7d8a80;
  border-radius: 6px;
  background: #fff;
  color: #1c2b22;
  font: inherit;
  font-size: 1rem;
}
.lg-split .lg-split__input:focus-visible,
.lg-split__link:focus-visible {
  outline: 2px solid #1f5f45;
  outline-offset: 2px;
}
.lg-split .lg-split__btn {
  min-height: 48px;
  border: 0;
  border-radius: 6px;
  background: #1f5f45;
  color: #fff;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
  transition: background-color .15s ease;
}
.lg-split .lg-split__btn:hover {
  background: #164633;
}
.lg-split .lg-split__btn:focus-visible {
  outline: 3px solid #1f5f45;
  outline-offset: 3px;
}
.lg-split__status {
  margin: 0;
  font-size: .875rem;
}
.lg-split__status:empty {
  display: none;
}
@container (min-width: 620px) {
  .lg-split__inner {
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    min-height: 32rem;
  }
  .lg-split__photo {
    height: auto;
  }
  .lg-split__caption {
    left: 1.25rem;
    bottom: 1.25rem;
    max-width: 16rem;
    padding: .9rem 1rem;
  }
  .lg-split__tag {
    display: block;
  }
  .lg-split__form {
    padding: 2.5rem clamp(1.5rem, 6cqi, 4rem);
  }
}

Sign-in dialog over the app

  • dialog element
  • @starting-style
  • :has()

Sign in to sync your notes

A dialog element holds the form, and :has(dialog[open]) dims the page behind it. Inside this preview the dialog opens with show() so it stays in the frame; on a real page call showModal(), which adds the top layer, ::backdrop, an inert background and Escape to close. The fade in uses @starting-style and only runs when reduced motion is off.

View code
.lg-modal {
  position: relative;
  width: 100%;
  max-width: 40rem;
  min-height: 30rem;
  overflow: hidden;
  border-radius: 14px;
  background: #fbfaf7;
  box-shadow: 0 12px 34px rgba(16, 24, 40, .1);
  color: #13233a;
}
.lg-modal__bar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 1rem;
  padding: .75rem 1rem;
  border-bottom: 1px solid #e3e1da;
}
.lg-modal__logo {
  font-family: "Fraunces", serif;
  font-size: 1.1rem;
  font-weight: 700;
}
.lg-modal .lg-modal__open {
  min-height: 40px;
  padding: 0 1.1rem;
  border: 0;
  border-radius: 999px;
  background: #13233a;
  color: #fff;
  font: inherit;
  font-size: .9375rem;
  font-weight: 600;
  cursor: pointer;
}
.lg-modal__body {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
  gap: .9rem;
  padding: 1.25rem 1rem;
}
.lg-modal__body span {
  height: 7rem;
  border-radius: 10px;
  background: #efece4;
}
.lg-modal__scrim {
  position: absolute;
  inset: 0;
  background: rgba(19, 35, 58, .5);
  opacity: 0;
  pointer-events: none;
  transition: opacity .2s ease;
}
.lg-modal:has(.lg-modal__dialog[open]) .lg-modal__scrim {
  opacity: 1;
}
.lg-modal .lg-modal__dialog {
  position: absolute;
  inset: 0;
  width: min(21rem, calc(100% - 2rem));
  height: fit-content;
  margin: auto;
  padding: 0;
  border: 0;
  border-radius: 14px;
  background: #fff;
  color: #13233a;
  box-shadow: 0 24px 60px rgba(8, 15, 30, .3);
}
.lg-modal__form {
  display: flex;
  flex-direction: column;
  gap: .4rem;
  padding: 1.25rem 1.25rem 1.4rem;
}
.lg-modal__head {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: .75rem;
  margin-bottom: .6rem;
}
.lg-modal__title {
  margin: 0;
  font-size: 1.2rem;
  font-weight: 700;
  line-height: 1.3;
}
.lg-modal .lg-modal__close {
  display: grid;
  flex: none;
  place-items: center;
  width: 36px;
  height: 36px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: #f0eee8;
  color: #13233a;
  cursor: pointer;
}
.lg-modal__label {
  margin-top: .45rem;
  font-size: .875rem;
  font-weight: 600;
}
.lg-modal .lg-modal__input {
  width: 100%;
  min-height: 46px;
  box-sizing: border-box;
  padding: .65rem .8rem;
  border: 1px solid #7f8796;
  border-radius: 8px;
  background: #fff;
  color: #13233a;
  font: inherit;
  font-size: 1rem;
}
.lg-modal .lg-modal__submit {
  min-height: 46px;
  margin-top: 1rem;
  border: 0;
  border-radius: 8px;
  background: #e0533d;
  color: #1a0b07;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
  transition: background-color .15s ease;
}
.lg-modal .lg-modal__submit:hover {
  background: #c94631;
}
.lg-modal .lg-modal__open:focus-visible,
.lg-modal .lg-modal__close:focus-visible,
.lg-modal .lg-modal__input:focus-visible,
.lg-modal .lg-modal__submit:focus-visible {
  outline: 2px solid #1f5fd1;
  outline-offset: 2px;
}
.lg-modal__status {
  margin: .4rem 0 0;
  font-size: .875rem;
}
.lg-modal__status:empty {
  display: none;
}
@media (prefers-reduced-motion: no-preference) {
  .lg-modal .lg-modal__dialog[open] {
    transition: opacity .22s ease, transform .22s ease;
  }
  @starting-style {
    .lg-modal .lg-modal__dialog[open] {
      opacity: 0;
      transform: translateY(10px) scale(.98);
    }
  }
}

Login form fields and states

The details inside the card: floating labels, error styling with :user-invalid, a password reveal, a caps lock warning, a one-time code input and the remember-me row. Each one keeps a real label and the right autocomplete token, so password managers still recognize the fields.

Floating labels with :placeholder-shown

  • :placeholder-shown
  • :focus-within
  • :autofill

Sign in to Tidepool

Type into the fields. Nothing is sent anywhere.

The label sits inside the field until the input gets focus or a value, then shrinks to the top edge. The input carries placeholder=" " (a single space) so :placeholder-shown can tell empty from filled, and the label stays a real label element. The :is(:autofill, :-webkit-autofill) rule keeps password manager fills readable without losing the rule in browsers that only know one name.

View code
.lg-float {
  display: flex;
  flex-direction: column;
  gap: 1rem;
  width: 100%;
  max-width: 21rem;
  box-sizing: border-box;
  padding: 1.75rem 1.5rem;
  border-radius: 18px;
  background: #fff;
  color: #0b2e2b;
  box-shadow: 0 12px 30px rgba(11, 46, 43, .1);
}
.lg-float__title {
  margin: 0 0 .25rem;
  font-size: 1.3rem;
  font-weight: 700;
}
.lg-float__field {
  position: relative;
}
.lg-float .lg-float__input {
  width: 100%;
  min-height: 58px;
  box-sizing: border-box;
  padding: 1.45rem .95rem .45rem;
  border: 1px solid #6f8583;
  border-radius: 12px;
  background: #fff;
  color: #0b2e2b;
  font: inherit;
  font-size: 1rem;
}
.lg-float__label {
  position: absolute;
  top: 1.05rem;
  left: .95rem;
  color: #4d6563;
  font-size: 1rem;
  line-height: 1.4;
  pointer-events: none;
  transform-origin: 0 0;
}
.lg-float .lg-float__input:focus + .lg-float__label,
.lg-float .lg-float__input:not(:placeholder-shown) + .lg-float__label {
  color: #0f766e;
  font-weight: 600;
  transform: translateY(-.62rem) scale(.78);
}
.lg-float .lg-float__input:focus-visible {
  border-color: #0f766e;
  outline: 2px solid #0f766e;
  outline-offset: 1px;
}
.lg-float .lg-float__input:is(:autofill, :-webkit-autofill) {
  box-shadow: inset 0 0 0 100px #e7f6f3;
  -webkit-text-fill-color: #0b2e2b;
}
.lg-float .lg-float__btn {
  min-height: 48px;
  border: 0;
  border-radius: 12px;
  background: #0f766e;
  color: #fff;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
  transition: background-color .15s ease;
}
.lg-float .lg-float__btn:hover {
  background: #0b5d57;
}
.lg-float .lg-float__btn:focus-visible {
  outline: 3px solid #0f766e;
  outline-offset: 3px;
}
.lg-float__status {
  margin: 0;
  font-size: .875rem;
}
.lg-float__status:empty {
  display: none;
}
@media (prefers-reduced-motion: no-preference) {
  .lg-float__label {
    transition: transform .18s ease, color .18s ease;
  }
}

Inline errors with :user-invalid and a summary

  • :user-invalid
  • aria-describedby
  • error summary

Log in to Copperline

Enter an email address like name@company.com.

Enter your password.

Type into the fields. Nothing is sent anywhere.

Red borders and messages appear only after you leave a field with a bad value, because the CSS keys off :user-invalid instead of :invalid. Pressing the button with empty fields adds a summary at the top that links to each problem. A small script sets aria-invalid and aria-describedby, since CSS can show a message but cannot announce it.

View code
.lg-check {
  display: flex;
  flex-direction: column;
  gap: 1rem;
  width: 100%;
  max-width: 22rem;
  box-sizing: border-box;
  padding: 1.75rem 1.5rem;
  border: 1px solid #d8dbe2;
  border-radius: 12px;
  background: #fff;
  color: #1e232d;
}
.lg-check__title {
  margin: 0;
  font-size: 1.3rem;
  font-weight: 700;
}
.lg-check__summary {
  padding: .8rem 1rem;
  border-left: 4px solid #b42318;
  border-radius: 6px;
  background: #fdf0ee;
  color: #6e140d;
}
.lg-check__summary:focus-visible {
  outline: 2px solid #b42318;
  outline-offset: 2px;
}
.lg-check__summary[hidden] {
  display: none;
}
.lg-check__summary-title {
  margin: 0 0 .3rem;
  font-weight: 700;
}
.lg-check__summary-list {
  margin: 0;
  padding-left: 1.1rem;
  font-size: .9rem;
}
.lg-check__summary-list a {
  color: #8a1a10;
}
.lg-check__field {
  display: flex;
  flex-direction: column;
  gap: .35rem;
}
.lg-check__label {
  font-size: .875rem;
  font-weight: 600;
}
.lg-check .lg-check__input {
  width: 100%;
  min-height: 46px;
  box-sizing: border-box;
  padding: .65rem .8rem;
  border: 1px solid #80868f;
  border-radius: 8px;
  background: #fff;
  color: #1e232d;
  font: inherit;
  font-size: 1rem;
}
.lg-check .lg-check__input:focus-visible {
  outline: 2px solid #2459c7;
  outline-offset: 2px;
}
.lg-check__error {
  display: none;
  margin: 0;
  padding-left: 1.4rem;
  color: #b42318;
  font-size: .875rem;
  background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='7' fill='%23b42318'/%3E%3Cpath d='M8 4v5' stroke='white' stroke-width='1.8' stroke-linecap='round'/%3E%3Ccircle cx='8' cy='11.6' r='1' fill='white'/%3E%3C/svg%3E") no-repeat 0 .15em / 1rem;
}
.lg-check .lg-check__input:user-invalid,
.lg-check.lg-check--submitted .lg-check__input:invalid {
  border-color: #b42318;
  border-width: 2px;
  background: #fffafa;
}
.lg-check .lg-check__input:user-invalid ~ .lg-check__error,
.lg-check.lg-check--submitted .lg-check__input:invalid ~ .lg-check__error {
  display: block;
}
.lg-check .lg-check__btn {
  min-height: 46px;
  border: 0;
  border-radius: 8px;
  background: #b4532a;
  color: #fff;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
  transition: background-color .15s ease;
}
.lg-check .lg-check__btn:hover {
  background: #933f1d;
}
.lg-check .lg-check__btn:focus-visible {
  outline: 3px solid #2459c7;
  outline-offset: 3px;
}
.lg-check__status {
  margin: 0;
  color: #1f6f43;
  font-size: .875rem;
}
.lg-check__status:empty {
  display: none;
}

Show and hide password toggle

  • aria-pressed
  • type swap
  • inline SVG

Welcome back to Orbitly

Type into the fields. Nothing is sent anywhere.

CSS alone cannot reveal a password, because the masking comes from type="password". A three-line script swaps the type to text and flips aria-pressed on the button, and the CSS swaps the eye icon from that attribute. The accessible name stays Show password in both states, which is how toggle buttons are meant to work. The field switches back to password on submit so browsers offer to save it.

View code
.lg-reveal {
  display: flex;
  flex-direction: column;
  gap: .4rem;
  width: 100%;
  max-width: 21rem;
  box-sizing: border-box;
  padding: 1.75rem 1.5rem;
  border-radius: 20px;
  background: #fff;
  color: #221a3d;
  box-shadow: 0 16px 40px rgba(60, 40, 140, .12);
}
.lg-reveal__title {
  margin: 0 0 .6rem;
  font-size: 1.3rem;
  font-weight: 700;
}
.lg-reveal__label {
  margin-top: .5rem;
  font-size: .875rem;
  font-weight: 600;
}
.lg-reveal__wrap {
  position: relative;
}
.lg-reveal .lg-reveal__input {
  width: 100%;
  min-height: 48px;
  box-sizing: border-box;
  padding: .7rem .9rem;
  border: 1px solid #8b84a3;
  border-radius: 12px;
  background: #faf9fe;
  color: #221a3d;
  font: inherit;
  font-size: 1rem;
}
.lg-reveal .lg-reveal__input--pass {
  padding-right: 3.4rem;
}
.lg-reveal .lg-reveal__input:focus-visible {
  border-color: #5b3cc4;
  outline: 2px solid #5b3cc4;
  outline-offset: 1px;
}
.lg-reveal .lg-reveal__toggle {
  position: absolute;
  top: 50%;
  right: 4px;
  display: grid;
  place-items: center;
  width: 44px;
  height: 40px;
  padding: 0;
  border: 0;
  border-radius: 9px;
  background: transparent;
  color: #4f3fa0;
  cursor: pointer;
  transform: translateY(-50%);
}
.lg-reveal .lg-reveal__toggle:hover {
  background: #eeeafb;
}
.lg-reveal .lg-reveal__toggle:focus-visible {
  outline: 2px solid #5b3cc4;
  outline-offset: 0;
}
.lg-reveal__toggle svg {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.lg-reveal__eye-off,
.lg-reveal__toggle[aria-pressed="true"] .lg-reveal__eye {
  display: none;
}
.lg-reveal__toggle[aria-pressed="true"] .lg-reveal__eye-off {
  display: block;
}
.lg-reveal__vh {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.lg-reveal .lg-reveal__btn {
  min-height: 48px;
  margin-top: 1.1rem;
  border: 0;
  border-radius: 12px;
  background: #5b3cc4;
  color: #fff;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
  transition: background-color .15s ease;
}
.lg-reveal .lg-reveal__btn:hover {
  background: #4a2fa6;
}
.lg-reveal .lg-reveal__btn:focus-visible {
  outline: 3px solid #5b3cc4;
  outline-offset: 3px;
}
.lg-reveal__status {
  margin: .5rem 0 0;
  font-size: .875rem;
}
.lg-reveal__status:empty {
  display: none;
}

Caps lock warning on the password field

  • getModifierState
  • hidden attribute
  • aria-describedby

Console sign in

Ironbark Cloud, region eu-north

Type into the fields. Nothing is sent anywhere.

A keyup listener reads event.getModifierState("CapsLock") and toggles the hidden attribute on a warning that the password field references through aria-describedby. The CSS only styles the warning, so it costs nothing when caps lock is off. Turn on caps lock and type in the password field to see it.

View code
.lg-caps {
  display: flex;
  flex-direction: column;
  gap: .4rem;
  width: 100%;
  max-width: 21rem;
  box-sizing: border-box;
  padding: 1.75rem 1.5rem;
  border: 1px solid #2c303a;
  border-radius: 14px;
  background: #15171d;
  color: #e9ebf1;
}
.lg-caps__title {
  margin: 0;
  font-size: 1.3rem;
  font-weight: 700;
}
.lg-caps__sub {
  margin: 0 0 .6rem;
  color: #a3a9b8;
  font-family: ui-monospace, Consolas, monospace;
  font-size: .8125rem;
}
.lg-caps__label {
  margin-top: .5rem;
  color: #c9cdd8;
  font-size: .875rem;
  font-weight: 600;
}
.lg-caps .lg-caps__input {
  width: 100%;
  min-height: 46px;
  box-sizing: border-box;
  padding: .65rem .8rem;
  border: 1px solid #5c6373;
  border-radius: 8px;
  background: #0d0f13;
  color: #f2f3f7;
  font: inherit;
  font-size: 1rem;
  color-scheme: dark;
}
.lg-caps .lg-caps__input:focus-visible {
  border-color: #f5b83d;
  outline: 2px solid #f5b83d;
  outline-offset: 1px;
}
.lg-caps__warn {
  display: flex;
  align-items: center;
  gap: .45rem;
  margin: .35rem 0 0;
  padding: .45rem .65rem;
  border-radius: 6px;
  background: #3a2b08;
  color: #ffd98a;
  font-size: .875rem;
  font-weight: 600;
}
.lg-caps__warn[hidden] {
  display: none;
}
.lg-caps .lg-caps__btn {
  min-height: 46px;
  margin-top: 1rem;
  border: 0;
  border-radius: 8px;
  background: #f5b83d;
  color: #1a1305;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
  transition: background-color .15s ease;
}
.lg-caps .lg-caps__btn:hover {
  background: #ffca5c;
}
.lg-caps .lg-caps__btn:focus-visible {
  outline: 3px solid #f5b83d;
  outline-offset: 3px;
}
.lg-caps__status {
  margin: .5rem 0 0;
  color: #a7e3bf;
  font-size: .875rem;
}
.lg-caps__status:empty {
  display: none;
}

Six-box one-time code input

  • one-time-code
  • inputmode
  • paste handling

Check your phone

Vaultline sent a 6-digit code to the number ending in 47.

Verification code

No code? Send a new one

Type into the fields. Nothing is sent anywhere.

Six inputs inside a fieldset, with autocomplete="one-time-code" on the first so phones can offer the code from a text message. The script moves focus forward as you type, back on Backspace, and spreads a pasted code across all six boxes, so pasting never fails. The boxes size with minmax(0, 3rem) and fit a 320px screen.

View code
.lg-otp {
  display: flex;
  flex-direction: column;
  gap: .9rem;
  width: 100%;
  max-width: 22rem;
  box-sizing: border-box;
  padding: 1.75rem 1.5rem;
  border-radius: 16px;
  background: #fff;
  color: #10204a;
  text-align: center;
  box-shadow: 0 12px 32px rgba(16, 32, 74, .1);
}
.lg-otp__title {
  margin: 0;
  font-size: 1.35rem;
  font-weight: 700;
}
.lg-otp__sub {
  margin: -.4rem 0 0;
  color: #4d5878;
  font-size: .9375rem;
}
.lg-otp__set {
  margin: 0;
  padding: 0;
  border: 0;
  min-width: 0;
}
.lg-otp__legend {
  margin-bottom: .6rem;
  padding: 0;
  font-size: .875rem;
  font-weight: 600;
}
.lg-otp__boxes {
  display: grid;
  grid-template-columns: repeat(6, minmax(0, 3rem));
  justify-content: center;
  gap: clamp(.3rem, 2vw, .55rem);
}
.lg-otp .lg-otp__box {
  width: 100%;
  height: 3.4rem;
  box-sizing: border-box;
  padding: 0;
  border: 1px solid #7b86a5;
  border-radius: 10px;
  background: #f6f8fd;
  color: #10204a;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 1.5rem;
  font-weight: 700;
  text-align: center;
  caret-color: #1e40af;
}
.lg-otp .lg-otp__box:focus-visible {
  border-color: #1e40af;
  background: #fff;
  outline: 2px solid #1e40af;
  outline-offset: 1px;
}
.lg-otp .lg-otp__box:not(:placeholder-shown):valid {
  border-color: #1e40af;
}
.lg-otp .lg-otp__btn {
  min-height: 48px;
  border: 0;
  border-radius: 10px;
  background: #1e40af;
  color: #fff;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
  transition: background-color .15s ease;
}
.lg-otp .lg-otp__btn:hover {
  background: #17328a;
}
.lg-otp .lg-otp__btn:focus-visible,
.lg-otp__link:focus-visible {
  outline: 3px solid #1e40af;
  outline-offset: 3px;
}
.lg-otp__status {
  margin: 0;
  font-size: .875rem;
}
.lg-otp__status:empty {
  display: none;
}
.lg-otp__resend {
  margin: 0;
  color: #4d5878;
  font-size: .9rem;
}
.lg-otp__link {
  display: inline-block;
  padding: .2rem 0;
  color: #1e40af;
  font-weight: 600;
}

Remember me and forgot password row

  • appearance: none
  • flex-wrap
  • 24px targets

Ember Kitchen staff login

Forgot password?

Type into the fields. Nothing is sent anywhere.

The checkbox is a native input restyled with appearance: none and an SVG check, so keyboard and screen reader support come for free. The label wraps it, which makes the whole line clickable, and padding lifts both the row and the forgot link above the 24 by 24 pixel target minimum. flex-wrap drops the link to its own line on narrow cards instead of squeezing it.

View code
.lg-keep {
  display: flex;
  flex-direction: column;
  gap: .4rem;
  width: 100%;
  max-width: 22rem;
  box-sizing: border-box;
  padding: 1.75rem 1.5rem;
  border-top: 6px solid #c2410c;
  border-radius: 6px;
  background: #fffaf5;
  color: #2a1a10;
  box-shadow: 0 10px 28px rgba(80, 40, 10, .1);
}
.lg-keep__title {
  margin: 0 0 .5rem;
  font-family: "Fraunces", serif;
  font-size: 1.4rem;
  font-weight: 600;
}
.lg-keep__label {
  margin-top: .5rem;
  font-size: .875rem;
  font-weight: 600;
}
.lg-keep .lg-keep__input {
  width: 100%;
  min-height: 46px;
  box-sizing: border-box;
  padding: .65rem .8rem;
  border: 1px solid #93806f;
  border-radius: 6px;
  background: #fff;
  color: #2a1a10;
  font: inherit;
  font-size: 1rem;
}
.lg-keep .lg-keep__input:focus-visible {
  border-color: #c2410c;
  outline: 2px solid #c2410c;
  outline-offset: 1px;
}
.lg-keep__row {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  align-items: center;
  gap: .25rem 1rem;
  margin-top: .6rem;
}
.lg-keep__remember {
  display: inline-flex;
  align-items: center;
  gap: .55rem;
  min-height: 44px;
  font-size: .9375rem;
  cursor: pointer;
}
.lg-keep .lg-keep__check {
  flex: none;
  width: 24px;
  height: 24px;
  margin: 0;
  border: 2px solid #8a6f5a;
  border-radius: 6px;
  background: #fff no-repeat center / 16px;
  cursor: pointer;
  -webkit-appearance: none;
  appearance: none;
}
.lg-keep .lg-keep__check:checked {
  border-color: #c2410c;
  background-color: #c2410c;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.5l3 3 6-7' fill='none' stroke='white' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
}
.lg-keep .lg-keep__check:focus-visible {
  outline: 2px solid #c2410c;
  outline-offset: 2px;
}
.lg-keep__link {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  color: #a5370a;
  font-size: .9375rem;
  font-weight: 600;
  text-decoration: none;
}
.lg-keep__link:hover {
  text-decoration: underline;
}
.lg-keep__link:focus-visible {
  outline: 2px solid #c2410c;
  outline-offset: 2px;
  border-radius: 3px;
}
.lg-keep .lg-keep__btn {
  min-height: 48px;
  margin-top: .5rem;
  border: 0;
  border-radius: 6px;
  background: #c2410c;
  color: #fff;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
  transition: background-color .15s ease;
}
.lg-keep .lg-keep__btn:hover {
  background: #9a330a;
}
.lg-keep .lg-keep__btn:focus-visible {
  outline: 3px solid #c2410c;
  outline-offset: 3px;
}
.lg-keep__status {
  margin: .5rem 0 0;
  font-size: .875rem;
}
.lg-keep__status:empty {
  display: none;
}

Sign-in flows

Login forms that change state: a passwordless email link, text-only social sign-in buttons, a loading state on the submit button and an identifier-first flow on a phone screen.

Text-only social sign-in buttons

  • flex divider
  • full-width buttons
  • visible text labels

Create beautiful invoices with Ledgerleaf

Sign in to continue

or use email

Provider buttons on top, an or divider drawn with flex and two border pseudo-elements, then the email form. Every button carries a visible text label, and the logos are left out on purpose: each identity provider publishes its own button rules for logo and color, so add those from their brand kits. A passkey button leads, since it needs no password at all.

View code
.lg-social {
  display: flex;
  flex-direction: column;
  gap: .7rem;
  width: 100%;
  max-width: 22rem;
  box-sizing: border-box;
  padding: 1.75rem 1.5rem;
  border: 1px solid #1d1d1f;
  border-radius: 4px;
  background: #fff;
  color: #1d1d1f;
  box-shadow: 6px 6px 0 #1d1d1f;
}
.lg-social__title {
  margin: 0;
  font-family: "Fraunces", serif;
  font-size: 1.35rem;
  font-weight: 600;
  line-height: 1.25;
}
.lg-social__sub {
  margin: -.3rem 0 .3rem;
  color: #55565c;
  font-size: .9375rem;
}
.lg-social__providers {
  display: flex;
  flex-direction: column;
  gap: .55rem;
}
.lg-social .lg-social__btn {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: .5rem;
  min-height: 46px;
  padding: 0 1rem;
  border: 1px solid #1d1d1f;
  border-radius: 4px;
  background: #fff;
  color: #1d1d1f;
  font: inherit;
  font-size: .9375rem;
  font-weight: 600;
  cursor: pointer;
  transition: background-color .15s ease;
}
.lg-social .lg-social__btn:hover {
  background: #f1f0ec;
}
.lg-social .lg-social__btn--key {
  background: #1d1d1f;
  color: #fff;
}
.lg-social .lg-social__btn--key:hover {
  background: #3a3a3e;
}
.lg-social__btn svg {
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
}
.lg-social__or {
  display: flex;
  align-items: center;
  gap: .75rem;
  margin: .4rem 0 0;
  color: #55565c;
  font-size: .875rem;
}
.lg-social__or::before,
.lg-social__or::after {
  content: "";
  flex: 1;
  border-top: 1px solid #b9b8b2;
}
.lg-social__form {
  display: flex;
  flex-direction: column;
  gap: .4rem;
}
.lg-social__label {
  font-size: .875rem;
  font-weight: 600;
}
.lg-social__inline {
  display: flex;
  gap: .5rem;
}
.lg-social .lg-social__input {
  flex: 1 1 0;
  width: 100%;
  min-width: 0;
  min-height: 46px;
  box-sizing: border-box;
  padding: .6rem .8rem;
  border: 1px solid #1d1d1f;
  border-radius: 4px;
  background: #fff;
  color: #1d1d1f;
  font: inherit;
  font-size: 1rem;
}
.lg-social .lg-social__go {
  min-height: 46px;
  padding: 0 1.1rem;
  border: 0;
  border-radius: 4px;
  background: #2f54eb;
  color: #fff;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.lg-social .lg-social__btn:focus-visible,
.lg-social .lg-social__input:focus-visible,
.lg-social .lg-social__go:focus-visible {
  outline: 3px solid #2f54eb;
  outline-offset: 2px;
}
.lg-social__status {
  margin: 0;
  font-size: .875rem;
}
.lg-social__status:empty {
  display: none;
}

Submit button with a loading state

  • aria-disabled
  • role="status"
  • reduced-motion fallback

Sign in to Pitchside

Match stats for amateur football clubs.

While the request runs, the button keeps its size and focus, shows a spinner and announces Signing in through a status region. It uses aria-disabled instead of disabled, so it stays in the tab order and a second press is simply ignored. With reduced motion on, the spinner is replaced by a static ring.

View code
.lg-load {
  display: flex;
  flex-direction: column;
  gap: .4rem;
  width: 100%;
  max-width: 21rem;
  box-sizing: border-box;
  padding: 1.75rem 1.5rem;
  border-radius: 16px;
  background: #18181d;
  color: #f1f1f4;
}
.lg-load__title {
  margin: 0;
  font-size: 1.35rem;
  font-weight: 700;
}
.lg-load__sub {
  margin: 0 0 .5rem;
  color: #a9a9b4;
  font-size: .9375rem;
}
.lg-load__label {
  margin-top: .5rem;
  color: #d0d0d8;
  font-size: .875rem;
  font-weight: 600;
}
.lg-load .lg-load__input {
  width: 100%;
  min-height: 46px;
  box-sizing: border-box;
  padding: .65rem .8rem;
  border: 1px solid #5d5d69;
  border-radius: 10px;
  background: #0f0f13;
  color: #f1f1f4;
  font: inherit;
  font-size: 1rem;
  color-scheme: dark;
}
.lg-load .lg-load__input:focus-visible {
  border-color: #facc15;
  outline: 2px solid #facc15;
  outline-offset: 1px;
}
.lg-load .lg-load__btn {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: .6rem;
  min-height: 48px;
  margin-top: 1rem;
  border: 0;
  border-radius: 10px;
  background: #facc15;
  color: #17140a;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
  transition: background-color .15s ease;
}
.lg-load .lg-load__btn:hover {
  background: #fde047;
}
.lg-load .lg-load__btn:focus-visible {
  outline: 3px solid #facc15;
  outline-offset: 3px;
}
.lg-load .lg-load__btn[aria-disabled="true"] {
  background: #d9b30f;
  cursor: progress;
}
.lg-load__spinner {
  display: none;
  width: 18px;
  height: 18px;
  box-sizing: border-box;
  border: 2.5px solid rgba(23, 20, 10, .3);
  border-top-color: #17140a;
  border-radius: 50%;
}
.lg-load__btn[aria-disabled="true"] .lg-load__spinner {
  display: block;
}
.lg-load__status {
  min-height: 1.3em;
  margin: .5rem 0 0;
  color: #c9c9d2;
  font-size: .875rem;
}
@media (prefers-reduced-motion: no-preference) {
  .lg-load__btn[aria-disabled="true"] .lg-load__spinner {
    animation: lg-spin .7s linear infinite;
  }
}
@keyframes lg-spin {
  to {
    transform: rotate(360deg);
  }
}

Identifier-first sign-in on a phone

  • two-step form
  • readonly username
  • thumb-zone button

Welcome back to Bloomday

Your plant care reminders are waiting.

The email comes first, then the password appears in the same form. The username field stays in the form as readonly instead of disappearing, so password managers still pair the two fields, and a Change button reopens it. The primary button is pinned to the bottom of the screen where a thumb reaches it.

View code
.lg-step {
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  height: 100%;
  box-sizing: border-box;
  padding: 2rem 1.25rem 1.5rem;
  background: #fdf6f8;
  color: #3a0d22;
}
.lg-step__top {
  display: flex;
  flex-direction: column;
  gap: .5rem;
}
.lg-step__logo {
  width: 48px;
  height: 48px;
  margin-bottom: 1rem;
  border-radius: 50% 50% 50% 8px;
  background: #be185d;
}
.lg-step__title {
  margin: 0;
  font-family: "Fraunces", serif;
  font-size: 1.65rem;
  font-weight: 600;
  line-height: 1.2;
}
.lg-step__sub {
  margin: 0 0 1rem;
  color: #6e3f53;
  font-size: .9375rem;
}
.lg-step__field {
  display: flex;
  flex-direction: column;
  gap: .4rem;
  margin-bottom: .6rem;
}
.lg-step__field[hidden] {
  display: none;
}
.lg-step__label {
  font-size: .875rem;
  font-weight: 600;
}
.lg-step__inline {
  position: relative;
}
.lg-step .lg-step__input {
  width: 100%;
  min-height: 52px;
  box-sizing: border-box;
  padding: .75rem .95rem;
  border: 1px solid #a07a8a;
  border-radius: 14px;
  background: #fff;
  color: #3a0d22;
  font: inherit;
  font-size: 1rem;
}
.lg-step .lg-step__input:read-only {
  padding-right: 5.5rem;
  border-color: #ecd3dd;
  background: #f8e7ee;
}
.lg-step .lg-step__input:focus-visible {
  border-color: #be185d;
  outline: 2px solid #be185d;
  outline-offset: 1px;
}
.lg-step .lg-step__change {
  position: absolute;
  top: 50%;
  right: 6px;
  display: none;
  min-height: 40px;
  padding: 0 .85rem;
  border: 0;
  border-radius: 10px;
  background: #fff;
  color: #9d1450;
  font: inherit;
  font-size: .875rem;
  font-weight: 700;
  cursor: pointer;
  transform: translateY(-50%);
}
.lg-step[data-step="2"] .lg-step__change {
  display: block;
}
.lg-step__link {
  align-self: flex-start;
  padding: .3rem 0;
  color: #9d1450;
  font-size: .9rem;
  font-weight: 600;
}
.lg-step .lg-step__btn {
  min-height: 54px;
  border: 0;
  border-radius: 16px;
  background: #be185d;
  color: #fff;
  font: inherit;
  font-size: 1.05rem;
  font-weight: 700;
  cursor: pointer;
  transition: background-color .15s ease;
}
.lg-step .lg-step__btn:hover {
  background: #9d1450;
}
.lg-step .lg-step__change:focus-visible,
.lg-step .lg-step__btn:focus-visible,
.lg-step__link:focus-visible {
  outline: 3px solid #be185d;
  outline-offset: 2px;
}
.lg-step__status {
  margin: 0;
  font-size: .875rem;
}
.lg-step__status:empty {
  display: none;
}

Login form styles and themes

The same few fields in three visual directions: a minimal underline style, a light and dark theme driven by light-dark() with a custom property fallback, and a frosted card over a photo.

Minimal underline inputs

  • border-bottom only
  • :focus-within
  • scaleX underline

Atelier Room

Members sign in to book studio time.

Inputs reduced to a single bottom rule, on a paper-colored card with a Fraunces heading. Focus draws a 3px terracotta line from the left through a scaled pseudo-element on the wrapper, and that line replaces the outline, so it is kept thick and above 3:1 against the card. The resting bottom border is dark enough to meet 3:1 as well, so the fields read as fields.

View code
.lg-line {
  display: flex;
  flex-direction: column;
  gap: 1.4rem;
  width: 100%;
  max-width: 22rem;
  box-sizing: border-box;
  padding: 2.25rem 1.75rem;
  background: #f6f1e7;
  color: #231d18;
  box-shadow: 0 1px 0 #e4dccb, 0 18px 40px rgba(60, 45, 25, .12);
}
.lg-line__title {
  margin: 0;
  font-family: "Fraunces", serif;
  font-size: 2rem;
  font-style: italic;
  font-weight: 500;
  line-height: 1.1;
}
.lg-line__sub {
  margin: -.9rem 0 .2rem;
  color: #5e5246;
  font-size: .9375rem;
}
.lg-line__field {
  display: flex;
  flex-direction: column;
}
.lg-line__label {
  color: #5e5246;
  font-size: .75rem;
  font-weight: 700;
  letter-spacing: .08em;
  text-transform: uppercase;
}
.lg-line__track {
  position: relative;
  display: block;
}
.lg-line__track::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 3px;
  background: #b4441c;
  transform: scaleX(0);
  transform-origin: 0 50%;
}
.lg-line__track:focus-within::after {
  transform: scaleX(1);
}
.lg-line .lg-line__input {
  width: 100%;
  min-height: 44px;
  box-sizing: border-box;
  padding: .5rem 0;
  border: 0;
  border-bottom: 1px solid #7a6c5d;
  border-radius: 0;
  background: transparent;
  color: #231d18;
  font: inherit;
  font-size: 1.05rem;
}
.lg-line .lg-line__input:focus {
  outline: none;
}
.lg-line .lg-line__btn {
  align-self: flex-start;
  min-width: 8rem;
  min-height: 48px;
  margin-top: .4rem;
  padding: 0 1.6rem;
  border: 0;
  border-radius: 999px;
  background: #231d18;
  color: #f6f1e7;
  font: inherit;
  font-weight: 600;
  letter-spacing: .02em;
  cursor: pointer;
  transition: background-color .15s ease;
}
.lg-line .lg-line__btn:hover {
  background: #b4441c;
}
.lg-line .lg-line__btn:focus-visible {
  outline: 2px solid #b4441c;
  outline-offset: 3px;
}
.lg-line__status {
  margin: -.6rem 0 0;
  font-size: .875rem;
}
.lg-line__status:empty {
  display: none;
}
@media (prefers-reduced-motion: no-preference) {
  .lg-line__track::after {
    transition: transform .25s ease;
  }
}

Light and dark theme with light-dark()

  • light-dark()
  • color-scheme
  • @supports fallback
Northbeam

Sign in to your dashboard

All colors live in custom properties. Where light-dark() is supported, each property holds both values and the color-scheme on the card picks one; older browsers fall back to a second set of properties under [data-theme="dark"]. Because color-scheme also reaches native controls, the checkbox switches with the theme too. The toggle is a button with aria-pressed.

View code
.lg-theme {
  --lg-th-bg: #ffffff;
  --lg-th-text: #161a23;
  --lg-th-muted: #5a6172;
  --lg-th-field: #f6f7f9;
  --lg-th-border: #858b99;
  --lg-th-accent: #2b59c3;
  --lg-th-on-accent: #ffffff;
  color-scheme: light;
  width: 100%;
  max-width: 22rem;
  box-sizing: border-box;
  padding: 1.25rem 1.5rem 1.75rem;
  border: 1px solid var(--lg-th-border);
  border-radius: 18px;
  background: var(--lg-th-bg);
  color: var(--lg-th-text);
  transition: background-color .25s ease, color .25s ease;
}
.lg-theme[data-theme="dark"] {
  --lg-th-bg: #14171e;
  --lg-th-text: #eceef3;
  --lg-th-muted: #a4abbb;
  --lg-th-field: #0c0e13;
  --lg-th-border: #5b6273;
  --lg-th-accent: #8cb0ff;
  --lg-th-on-accent: #0b1430;
  color-scheme: dark;
}
@supports (color: light-dark(#000, #fff)) {
  .lg-theme,
  .lg-theme[data-theme="dark"] {
    --lg-th-bg: light-dark(#ffffff, #14171e);
    --lg-th-text: light-dark(#161a23, #eceef3);
    --lg-th-muted: light-dark(#5a6172, #a4abbb);
    --lg-th-field: light-dark(#f6f7f9, #0c0e13);
    --lg-th-border: light-dark(#858b99, #5b6273);
    --lg-th-accent: light-dark(#2b59c3, #8cb0ff);
    --lg-th-on-accent: light-dark(#ffffff, #0b1430);
  }
}
.lg-theme__head {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: .75rem;
  margin-bottom: 1.25rem;
}
.lg-theme__brand {
  font-weight: 800;
  letter-spacing: -.01em;
}
.lg-theme .lg-theme__toggle {
  display: inline-flex;
  align-items: center;
  gap: .4rem;
  min-height: 40px;
  padding: 0 .8rem;
  border: 1px solid var(--lg-th-border);
  border-radius: 999px;
  background: transparent;
  color: var(--lg-th-text);
  font: inherit;
  font-size: .875rem;
  font-weight: 600;
  cursor: pointer;
}
.lg-theme__toggle svg {
  fill: currentColor;
}
.lg-theme__toggle[aria-pressed="true"] {
  border-color: var(--lg-th-accent);
  color: var(--lg-th-accent);
}
.lg-theme__form {
  display: flex;
  flex-direction: column;
  gap: .4rem;
}
.lg-theme__title {
  margin: 0 0 .5rem;
  font-size: 1.3rem;
  font-weight: 700;
}
.lg-theme__label {
  margin-top: .45rem;
  color: var(--lg-th-muted);
  font-size: .875rem;
  font-weight: 600;
}
.lg-theme .lg-theme__input {
  width: 100%;
  min-height: 46px;
  box-sizing: border-box;
  padding: .65rem .8rem;
  border: 1px solid var(--lg-th-border);
  border-radius: 10px;
  background: var(--lg-th-field);
  color: var(--lg-th-text);
  font: inherit;
  font-size: 1rem;
}
.lg-theme__check {
  display: flex;
  align-items: center;
  gap: .55rem;
  min-height: 44px;
  margin-top: .3rem;
  font-size: .9375rem;
  cursor: pointer;
}
.lg-theme__check input {
  width: 20px;
  height: 20px;
  margin: 0;
  accent-color: var(--lg-th-accent);
}
.lg-theme .lg-theme__btn {
  min-height: 48px;
  margin-top: .5rem;
  border: 0;
  border-radius: 10px;
  background: var(--lg-th-accent);
  color: var(--lg-th-on-accent);
  font: inherit;
  font-weight: 700;
  cursor: pointer;
}
.lg-theme .lg-theme__toggle:focus-visible,
.lg-theme .lg-theme__input:focus-visible,
.lg-theme__check input:focus-visible,
.lg-theme .lg-theme__btn:focus-visible {
  outline: 2px solid var(--lg-th-accent);
  outline-offset: 2px;
}
.lg-theme__status {
  margin: .4rem 0 0;
  font-size: .875rem;
}
.lg-theme__status:empty {
  display: none;
}

Frosted card over a photo

  • backdrop-filter
  • @supports
  • object-fit: cover

Mesa Field Notes

Log in to sync your trail journal.

A full-bleed photo sits behind a card with a semi-transparent white background and backdrop-filter: blur(). The card is 78 percent opaque, so the text keeps strong contrast even over the darkest rock in the picture, and an @supports fallback makes it nearly solid where the blur is missing.

View code
.lg-frost {
  position: relative;
  display: grid;
  width: 100%;
  place-items: center;
  min-height: 34rem;
  padding: 2rem 1rem;
  box-sizing: border-box;
  overflow: hidden;
  background: #7a3b1c;
}
.lg-frost__bg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.lg-frost__card {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: .4rem;
  width: 100%;
  max-width: 21rem;
  box-sizing: border-box;
  padding: 1.75rem 1.5rem;
  border: 1px solid rgba(255, 255, 255, .6);
  border-radius: 20px;
  background: rgba(255, 250, 245, .94);
  color: #241710;
  box-shadow: 0 20px 50px rgba(40, 15, 5, .3);
}
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .lg-frost__card {
    background: rgba(255, 250, 245, .78);
    -webkit-backdrop-filter: blur(14px) saturate(1.2);
    backdrop-filter: blur(14px) saturate(1.2);
  }
}
.lg-frost__title {
  margin: 0;
  font-family: "Fraunces", serif;
  font-size: 1.6rem;
  font-weight: 700;
}
.lg-frost__sub {
  margin: 0 0 .5rem;
  color: #4e3a2e;
  font-size: .9375rem;
}
.lg-frost__label {
  margin-top: .45rem;
  font-size: .875rem;
  font-weight: 600;
}
.lg-frost .lg-frost__input {
  width: 100%;
  min-height: 46px;
  box-sizing: border-box;
  padding: .65rem .8rem;
  border: 1px solid #8a6e5e;
  border-radius: 12px;
  background: rgba(255, 255, 255, .85);
  color: #241710;
  font: inherit;
  font-size: 1rem;
}
.lg-frost .lg-frost__input:focus-visible {
  border-color: #9a3412;
  outline: 2px solid #9a3412;
  outline-offset: 1px;
}
.lg-frost .lg-frost__btn {
  min-height: 48px;
  margin-top: 1rem;
  border: 0;
  border-radius: 12px;
  background: #9a3412;
  color: #fff;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
  transition: background-color .15s ease;
}
.lg-frost .lg-frost__btn:hover {
  background: #7c2a0e;
}
.lg-frost .lg-frost__btn:focus-visible {
  outline: 3px solid #9a3412;
  outline-offset: 3px;
}
.lg-frost__status {
  margin: .4rem 0 0;
  font-size: .875rem;
}
.lg-frost__status:empty {
  display: none;
}

Which HTML markup does a CSS login form need?

Strip it down and you have a form element, two labeled fields and a submit button. Wrappers, icons and extra links are optional, and mostly just give the stylesheet more to grab onto.

CSS can only restyle what the underlying HTML gives it. Gaps in the markup turn into layout hacks later.

  • Every label gets a for value that matches the id on its input, so a click on the label focuses the field.
  • type=”email” on the first field brings up the email keyboard on phones, while type=”text” suits sign-in by username. type=”password” masks the characters, and no CSS property changes that.
  • autocomplete=”username” and autocomplete=”current-password” tell browsers and password managers which field is which.
  • The button is type=”submit” and sits inside the form, so Enter submits it without JavaScript.

Skip autocomplete=”off”. Can I use compatibility notes record that Firefox ignores it on login forms and Safari ignores it on username, email and password fields.

MDN’s issue tracker also carried a request (issue 19834, now closed) to document how browsers decide which input is the username. Explicit tokens beat relying on field names.

I keep one class per element (login-form, login-field, login-button). Selectors stay flat and easy to override.

How to center and size a CSS login form on any screen

Put the form in a wrapper with display: flex, center it on both axes, and give the card width: 100% with a max-width around 24rem. On desktop the card holds its size. On phones it shrinks to fit the screen.

Centering with Flexbox

Set justify-content: center and align-items: center on the wrapper. It also needs a height to center against, and min-height: 100dvh is the safer value.

On phones, 100vh equals the large viewport, which is taller than the visible area while the address bar shows, so the card sits low. The newer viewport units fix that. MDN documents svh, lvh and dvh as the small, large and dynamic variants, and dvh follows the browser toolbar as it moves.

MDN also warns that dynamic units can resize content while the user scrolls. A login page rarely scrolls, so dvh fits here. If yours does scroll, svh is the stable fallback.

Aligning labels and inputs with Grid

Stacked labels sit above their input in one column, and a single display: grid declaration with a gap spaces every row evenly. Inline labels use grid-template-columns: auto 1fr, which puts labels on the left and inputs on the right. That suits wide admin forms. It breaks down on narrow screens and with long translated labels.

For a login form, stack them. Two fields don’t justify a label column.

Scaling the card on small screens

Write the base rules for the narrowest screen first. Add a media query with a min-width only where the layout changes, such as a split-screen version with an illustration panel.

  • Use rem for spacing and font sizes, so they follow the user’s browser setting.
  • Keep the input font-size at 1rem (16px) or more, because iOS Safari zooms into smaller focused inputs.

And the viewport meta tag has to be there, with width=device-width, initial-scale=1. Without it phones lay the page out at a desktop width, and the breakpoints fire against that width.

Styling inputs, labels, and the submit button

Inputs need padding, a visible border, a border radius and box-sizing: border-box. The button wants full width, plus hover and disabled styles.

Without border-box, an input set to width: 100% plus padding overflows the card on the right. Browser defaults for font, border and background also differ between inputs and buttons, so the rules for styling text inputs start with font: inherit and an explicit border.

Put the soft box-shadow on the card, not on each input. Shadows on fields compete with the focus ring, and a box shadow generator makes it quick to tune blur and spread on the card itself.

Give the button a background-color transition and a darker hover color. For the disabled style, reduced opacity and cursor: not-allowed do the job.

Static label, floating label, or placeholder

Labels can be static, floating, or replaced by a placeholder, and each option fails in a different place.

A static label above the input is always visible and needs no extra markup or CSS tricks. It never overlaps an autofilled value either. What it costs is vertical space on short screens.

A floating label is compact, and the label stays visible after typing. It follows the floating label pattern popularized by Material Design text fields. The catch is that the input needs a placeholder attribute (a single space works) so :placeholder-shown can trigger the move, and the shrunken label text is harder to read, so it has to keep its contrast.

Placeholder-only has the least markup, and that’s about all it has going for it. The text vanishes as soon as the user types, and Deque’s accessibility guidance lists failing color contrast as a standing problem with placeholders.

Use the static label by default. A floating label works when the card must stay compact and a real label element stays in the markup. Placeholder-only belongs in prototypes.

Icons inside fields

Position the icon absolutely inside a relatively positioned wrapper, then add left padding to the input so typed text clears it. Font Awesome and inline SVG both do the job.

Decorative icons take aria-hidden=”true” so screen readers skip them. The label still names the field.

How to style focus, hover, and autofill states

Keyboard focus rings come from :focus-visible. The button gets :hover and :disabled, and filled fields get :autofill alongside its -webkit-autofill alias. Each one touches different properties.

SelectorApplies toWhat to change
:focus-visibleInputs, button, linksoutline, border-color
:hoverButton, linksbackground-color
:disabledSubmit buttonopacity, cursor
:autofillInputsbox-shadow, text color

Focus and focus-visible

MDN lists :focus-visible as Baseline Widely available, supported across browsers since March 2022.

It matches only when the browser decides the user needs to see where focus sits. The keyboard ring on the button appears for Tab users and not for mouse clicks, while text inputs match on click as well because they accept typing.

Never remove the outline without a replacement. MDN’s :focus page flags a bare outline: none and points to the 3:1 contrast requirement for focus indicators in WCAG 2.1 SC 1.4.11.

An outline: 2px solid in a color that reaches 3:1 against the card works, with outline-offset: 2px so the ring clears the border. A border-color change on inputs makes a decent second cue.

WCAG 2.2 also adds 2.4.13 Focus Appearance at level AAA, which asks for an indicator at least as large as a 2 CSS pixel perimeter with 3:1 contrast between the focused and unfocused states. A solid 2px outline around the field usually meets it.

Chrome DevTools forces :hover, :focus and :focus-visible from the :hov panel in the Styles pane, so each state can be inspected without tabbing through the form.

Hover and disabled

Hover exists only with a pointer. Touch screens never trigger it, so nothing a user needs should depend on it.

On the submit button, darken the background-color and add a short transition. A library of hover effects covers fancier treatments, though a login button gains little from them.

For :disabled, lower the opacity and set cursor: not-allowed. The button still needs enough contrast to read as a button.

Autofill

web-features lists :autofill as Limited availability. Chrome and Edge support it from version 110 (February 2023) and Safari from 15 (September 2021). Firefox 86 and later match it only on autofilled username and password fields, which MDN’s compat data records as partial support.

The prefixed -webkit-autofill is the specification’s own alias, and versions before Chrome 110 and Safari 15 know only that form. Wrapping both in :is() solves this, because :is() takes a forgiving selector list, so a browser that does not recognize one selector does not discard the whole rule.

Chrome paints autofilled fields with its own background, and a plain background-color rule loses to it. The common workaround is a large inset box-shadow in the card color plus -webkit-text-fill-color for the text, a pattern CSS-Tricks documents.

Firefox support is partial, so the form also has to look right with the browser’s default autofill color. The pseudo-class also stops matching once the user edits the field.

How do :invalid and :user-invalid style form errors?

Use :user-invalid for error styling. :invalid has a habit of flagging an empty required field before anyone has touched it.

Why :invalid fires too early

:invalid matches from the first render, so a fresh login form shows red borders on both fields before anyone types.

:user-invalid holds off until the user interacts, typically by leaving the field. The web.dev Baseline digest for May 2026 describes it the same way, as feedback shown after the user leaves the field.

Both selectors read the browser’s constraint validation state, the same state the Constraint Validation API exposes to JavaScript. In a login form, required and type=”email” do the work.

Leave pattern and minlength off the password field. Those rules belong on sign-up, and at login they lock out users whose old passwords predate the current rules.

For the message, CSS can show or hide an element (input:user-invalid + .error { display: block }) but cannot write its text.

Connect each message to its field with aria-describedby, one of the ARIA attributes, so screen readers announce the error together with the field. Pair the red color with an icon or text, so color is never the only signal.

Browser support for :user-invalid

Release dates come from the web-features dataset, and the Widely available status comes from web.dev’s May 2026 Baseline digest.

  • Firefox 88, released 2021-04-19
  • Safari 16.5, released 2023-05-18 (Safari on iOS 16.5 as well)
  • Chrome 119, released 2023-10-31
  • Edge 119, released 2023-11-02
  • Baseline Newly available since 2023-11-02, Baseline Widely available since May 2026

Sources disagree on Firefox. MDN’s compat data lists Firefox 4 to 87 under the prefixed name :-moz-ui-invalid, while web-features starts Firefox at 88.

Both are accurate. The prefixed selector existed, but only the unprefixed :user-invalid works in a shared stylesheet, so 88 is the number that counts.

With Widely available status reached, a fallback only matters for browsers older than Chrome 119, Safari 16.5 or Firefox 88.

Theming a CSS login form with custom properties and dark mode

Define colors, radius and spacing once as custom properties on :root, redefine the color values inside a prefers-color-scheme: dark media query, and set color-scheme: light dark so native controls follow. Every component rule then reads from variables and never from a literal color.

A starting set would be –color-bg, –color-surface and –color-text, then –color-border, –color-accent and –color-error, plus –radius and –space-unit.

color-scheme does work the variables cannot. MDN lists form controls, scrollbars and system colors among the things it affects, and recommends a matching color-scheme meta tag ahead of any CSS to prevent a flash of the wrong theme while the page loads.

The light-dark() function folds both palettes into one declaration, for example color: light-dark(#1b1b1b, #f2f2f2). MDN notes it only works once color-scheme: light dark is set, usually on :root.

web-features lists light-dark() as Baseline Newly available since 2024-05-13, with Chrome 123, Firefox 120 and Safari 17.5 as the first supporting versions. Its projected Widely available date is 2026-11-13, which is still ahead as of October 2026.

Until then, put a plain color declaration before the light-dark() line. A browser that does not understand the function discards the second declaration and keeps the first.

If older browsers matter, go with the media query. light-dark() earns its place when having each pair of values side by side is worth more than the reach.

Dark palettes tend to break contrast in input borders and in error colors, so test both. An accessible color palette generator helps produce dark variants that hold up.

What does WCAG 2.2 require from a login form’s CSS?

Text contrast, focus not obscured, target size and accessible authentication cover most of it, with the 1.4.11 threshold for focus indicators sitting on top.

  • 1.4.3 Contrast (Minimum) asks for text at 4.5:1 or higher.
  • 2.4.11 Focus Not Obscured (Minimum) means a focused component is not entirely hidden due to author-created content.
  • 2.5.8 Target Size (Minimum) sets pointer targets at 24 by 24 CSS pixels or more, unless an exception applies.
  • 3.3.8 Accessible Authentication (Minimum) says no step may demand a cognitive function test unless an alternative, an assisting mechanism or one of two narrow exceptions (object recognition, personal content) is available.

Wording follows the W3C Understanding documents for WCAG 2.2.

Contrast and labels

The WebAIM Million 2026 found low-contrast text on 83.9% of the one million home pages it scanned. Among the form inputs it counted, 33.1% were not properly labeled through a label element, aria-label, aria-labelledby or title.

W3C’s Understanding document for 1.4.3 states that the criterion covers placeholder text and treats the ratio as a hard threshold. A measured 4.499:1 does not pass 4.5:1.

Measure instead of eyeballing. The WebAIM contrast checker tests a single pair of colors, and the Lighthouse audit in Chrome DevTools flags insufficient color contrast across the whole page.

Lighthouse’s own report adds that automated checks do not replace manual testing.

Focus and target size in practice

W3C names sticky footers, sticky headers and non-modal dialogs as typical overlays for 2.4.11, and says a sticky cookie banner that entirely covers a focused component fails. scroll-padding on the scrolling container keeps a focused field clear of a sticky bar.

Target size is easier. The inputs and the submit button clear 24 by 24 CSS pixels through padding alone. The show/hide icon and the remember-me checkbox are the usual misses, and so is a forgot-password link that stands alone.

An undersized target still passes when a 24-pixel circle centered on it touches no other target. Links inside a sentence are exempt, but a link on its own line is not.

Password managers, paste, and autofill

3.3.8 asks for a login that works without memorizing or retyping a password.

W3C’s Understanding document says a form meeting 1.3.5 Input Purpose, with properly named controls, lets browsers and password managers recognize and fill the fields. It also counts blocking copy and paste as a failure, and W3C technique H100 for this criterion includes checking that each input allows pasting.

CSS cannot block paste. It can still undermine the criterion by hiding a label or by restyling the autofill state until filled values turn unreadable, and naming every field properly is the foundation of accessible forms anyway.

Plain CSS, Bootstrap, or Tailwind CSS for a login form

Plain CSS suits a standalone login page, and a project that already loads a framework should use that framework. Adding one for a single form brings a full stylesheet to style two inputs and a button.

ApproachHow styling is appliedStrengthWhere it falls short
Plain CSSOwn stylesheet and custom propertiesFull control, no dependencyEvery state and theme written by hand
Bootstrapform-control and form-floating classesFast, consistent defaultsRecognizable look, overrides needed
Tailwind CSS with @tailwindcss/formsUtility classes on a plugin resetQuick iteration in the markupLong class lists, reset only, no components

Bootstrap’s documentation styles text inputs with the form-control class, and its floating label pattern wraps an input.form-control and its label in a form-floating container. Most Bootstrap login form patterns follow that markup, with the label after the input and a placeholder attribute on the input.

The @tailwindcss/forms README calls the plugin a basic reset that makes form elements easy to override with utilities, and recommends treating it as a reset rather than a set of components. Its strategy option picks global styles (base) or form-* classes (class), and Tailwind form styling then happens in the markup.

If the project already has a framework, use its form classes and override colors with custom properties. A new standalone page gets plain CSS, and a Tailwind project gets the forms plugin plus utilities.

Neither framework removes the autofill and contrast work covered above.

Where a CSS login form breaks or stops working

CSS controls how a login form looks and has no say in whether a login succeeds. Everything that touches credentials, security or behavior sits outside the stylesheet.

What CSS cannot do

It cannot check credentials. Authentication runs on the server, and :user-invalid only reflects the browser’s own constraint checks. Client-side rules can be bypassed too, so the server validates again.

Revealing a password takes a script as well, because a show/hide toggle has to switch the input between the password and text types. Passkey sign-in works the same way. W3C’s ACT rules accept the autocomplete value current-password webauthn, and the passkey flow itself runs through script.

The reveal toggle is the one place a login form almost always needs JavaScript. Give its button an accessible name, since an icon alone has none.

Failures that start in the markup

A disabled submit button drops out of the tab order and gives no reason for the lock. Keep it enabled and show the error.

Tutorials also love restyling the checkbox through a span that jQuery toggles, and Designmodo’s login form tutorial (updated July 2024) still does. A native checkbox styled with appearance: none keeps keyboard and screen reader support without the script.

Chromium-only testing hides the rest. Run a cross-browser compatibility pass in Safari and Firefox before shipping, since each engine handles form controls and autofill styling its own way.

CSS Login Form FAQ

What is the difference between a login form, a login page and a sign-up form?

The form itself is just the fields, labels and submit button. Wrap it in branding and links and you have a login page.

A sign-up form collects new account details, and it uses autocomplete=”new-password” where a login form uses current-password.

How do you style a remember me checkbox and a forgot password link?

Keep the native checkbox and tint it with accent-color, or restyle the checkbox with appearance: none. Wrap the checkbox in its label so the whole line is clickable.

Give the forgot password link a contrast-safe color and an underline on hover. Padding gives it a target big enough to tap.

How should social login buttons be styled?

Place them below the form, behind an “or” divider, as full-width buttons that match the submit button’s height. Each one needs a visible text label next to its icon.

Follow each identity provider’s own button guidelines for logo and color.

Can a login form use animation or a glassmorphism look?

Yes, with limits. A short keyframe animation on the error message or a transition on focus works, and prefers-reduced-motion should switch it off for users who ask.

For a frosted card, the glassmorphism effect pairs a semi-transparent background with backdrop-filter: blur(). Test text contrast against the blurred backdrop, because it changes with whatever sits behind the card.

Does a split-screen login page need JavaScript?

No. A two-column grid or flex container holds the form card in one column and an image or brand panel in the other.

Hide the panel below a breakpoint with a media query, so phones show only the form.

Which sources offer ready-made login form templates?

CodePen, GitHub repositories and the component libraries for Bootstrap and Tailwind CSS all host login form templates.

Whatever the source, check the template for real labels, a visible focus ring and autocomplete tokens before it ships.

Testing a CSS Login Form Before It Ships

A CSS login form is ready to ship when a keyboard, a password manager and a real phone each complete a sign-in without help. Run them in that order.

  • Tab through every field and the button.
  • Fill, paste and read the filled values with a password manager.
  • Load it on an actual phone and sign in with a thumb, address bar included.

The order follows cost and consequence. A broken tab order or an unreadable filled value blocks sign-in outright, while a misplaced card on a phone only looks wrong.

No audit runs a password manager, so the second pass has no automated substitute. A one-time code step widens the job, because W3C’s Understanding document for 3.3.8 applies the criterion to every step of a multi-step sign-in.

The trade-off is time, since three manual passes take longer than one Lighthouse run. A full web accessibility checklist turns them into a routine for every form on the site.