A red error tooltip under an email field that shakes once as it opens, and turns into an accent Looks good note once the address is valid. It reads :invalid, so it needs no script.
Also known as: input error tooltip css, form validation tooltip, invalid input tooltip.
<span class="tip-input-error"> <input type="email" value="maya@" required aria-label="Email address" aria-describedby="tip-input-error-tip"> <span class="tip-input-error-icon" aria-hidden="true"><svg class="tip-input-error-bad" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5.5M12 16.4v.1"/></svg><svg class="tip-input-error-good" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="m8 12.3 2.8 2.8L16.2 9.5"/></svg></span> <span class="tip-input-error-tip" id="tip-input-error-tip" role="tooltip"><span class="tip-input-error-bad">Add a domain after the @, like maya@studio.com</span><span class="tip-input-error-good">Looks good</span></span> </span>
.tip-input-error {
--c: var(--tip-color, #3b82f6);
font-size: var(--tip-size, 16px);
position: relative;
display: inline-flex;
--bad: #e11d48; /* errors stay red whatever the accent */
margin-bottom: 3.75em; /* room for the tooltip in this preview */
}
.tip-input-error input {
box-sizing: border-box;
width: 15em;
padding: 0.65em 2.6em 0.65em 0.85em;
border: 1px solid var(--c);
border-radius: 0.55em;
background: #fff;
color: #0f172a;
font: 500 1em/1.2 system-ui, sans-serif;
caret-color: var(--c);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.tip-input-error input:invalid {
border-color: var(--bad);
box-shadow: 0 0 0 0.2em color-mix(in srgb, var(--bad) 14%, transparent);
}
.tip-input-error input:focus-visible {
outline: 2px solid var(--c);
outline-offset: 2px;
}
.tip-input-error input:invalid:focus-visible { outline-color: var(--bad); }
.tip-input-error-icon {
position: absolute;
top: 50%;
right: 0.75em;
display: grid;
color: var(--c);
pointer-events: none;
transform: translateY(-50%);
}
.tip-input-error-icon svg {
grid-area: 1 / 1;
width: 1.25em;
height: 1.25em;
}
.tip-input-error input:invalid ~ .tip-input-error-icon { color: var(--bad); }
/* one of each pair shows, depending on the field's validity */
.tip-input-error input:valid ~ * .tip-input-error-bad,
.tip-input-error input:invalid ~ * .tip-input-error-good { display: none; }
.tip-input-error-tip {
position: absolute;
top: calc(100% + 0.75em);
right: 0;
left: 0;
z-index: 10;
padding: 0.5em 0.8em;
border-radius: 0.5em;
background: var(--c);
box-shadow: 0 0.5em 1em -0.4em rgb(15 23 42 / 0.45);
color: #fff;
font: 500 0.8em/1.4 system-ui, sans-serif;
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translateY(-0.4em);
transition: opacity 0.18s ease, transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1), visibility 0.3s;
}
.tip-input-error input:invalid ~ .tip-input-error-tip { background: var(--bad); }
/* the arrow sits under the status icon: 0.75em in plus half of 1.25em, in this box's 0.8em font */
.tip-input-error-tip::before {
content: "";
position: absolute;
right: 1.72em;
bottom: 100%;
width: 0.6em;
height: 0.6em;
background: inherit;
transform: translate(50%, 50%) rotate(45deg);
}
.tip-input-error:hover .tip-input-error-tip,
.tip-input-error:focus-within .tip-input-error-tip {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.tip-input-error:hover input:invalid ~ .tip-input-error-tip,
.tip-input-error:focus-within input:invalid ~ .tip-input-error-tip {
animation: tip-input-error-shake 0.5s cubic-bezier(0.36, 0.07, 0.19, 0.97) 0.15s both;
}
@keyframes tip-input-error-shake {
0%, 100% { translate: 0; }
20% { translate: -0.35em; }
40% { translate: 0.3em; }
60% { translate: -0.2em; }
80% { translate: 0.1em; }
}
@media (prefers-reduced-motion: reduce) {
.tip-input-error, .tip-input-error *, .tip-input-error::before, .tip-input-error::after, .tip-input-error *::before, .tip-input-error *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}--tip-color on the element or a parent.
Currently previewing #3b82f6.--tip-size. Every other dimension is in
em, so the whole thing scales together. Currently previewing 16px.transition or animation
shorthand. Staggered delays are relative, so they keep their rhythm.