09Focus ring that walks you to the first error
On a failed submit, the scroll box glides down to the first invalid field, a thick ring lights on it, and focus is handed over without jerking the page.
Best forLong application forms where fields fall off-screen
scrollIntoViewpreventScrolloutline

<main class="stage">
<form class="fe is-demo" id="fe" novalidate>
<div class="fe__viewport" id="fe-viewport">
<div class="fe__track" id="fe-track">
<div class="fe__row"><span class="fe__cap">Name</span><span class="fe__slot" data-filled="1">Bitna Kim</span></div>
<div class="fe__row"><span class="fe__cap">Birth date</span><span class="fe__slot" data-filled="1">1996-04-02</span></div>
<div class="fe__row" id="fe-bad"><span class="fe__cap">Email</span><span class="fe__slot" data-filled="0" tabindex="0" aria-invalid="true" aria-describedby="fe-say"></span></div>
<div class="fe__row"><span class="fe__cap">Address</span><span class="fe__slot" data-filled="1">Seongsu-dong 12-gil</span></div>
</div>
</div>
<button class="fe__btn" type="submit">Submit form</button>
<p class="fe__say" id="fe-say" role="status" aria-live="polite">Moved to the empty email field</p>
</form>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.fe {
box-sizing: border-box;
width: min(300px, 86vw);
display: flex;
flex-direction: column;
gap: $sp-2;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
// 스크롤 상자(viewport)와 줄 묶음(track)을 나눈다 — 움직이는 것은 언제나 track 쪽뿐이다
.fe__viewport {
position: relative;
height: 96px;
overflow-y: auto;
border-radius: $r-card;
background: rgba(255, 255, 255, .05);
}
.fe__track {
display: flex;
flex-direction: column;
gap: $sp-2;
padding: $sp-2;
}
.fe__row {
display: flex;
align-items: center;
gap: $sp-2;
}
.fe__cap {
flex: 0 0 auto;
width: 52px;
color: rgba(255, 255, 255, .5);
font-size: 11px; line-height: 14px; font-weight: 700;
}
.fe__slot {
box-sizing: border-box;
flex: 1 1 auto;
height: 32px;
padding: 0 $sp-3;
border: 2px solid rgba(255, 255, 255, .14);
border-radius: $r-control;
color: $subject-cream;
font-size: 13px; line-height: 28px; font-weight: 700;
outline: 0 solid transparent;
transition: outline-color $dur-quick $ease-out, border-color $dur-quick $ease-out;
}
// 색만 애니메이션하려면 굵기가 먼저 있어야 한다 — 0px 짜리 outline 은 색을 바꿔도 안 보인다
.fe__slot[data-filled="0"] { border-color: $error; outline: 3px solid transparent; outline-offset: 2px; }
.fe__slot.is-hit,
.fe__slot:focus-visible {
outline: 3px solid $color;
outline-offset: 2px;
}
.fe__btn {
height: 34px;
border: 0;
border-radius: $r-control;
background: $subject-cream;
color: $subject-ink;
font-family: inherit;
font-size: 13px; line-height: 34px; font-weight: 700;
cursor: pointer;
transition: transform $dur-instant $ease-out;
}
.fe__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.fe__btn:active { transform: scale(.97); }
.fe__say {
// 어디로 옮겼는지는 링이 보여 준다 — 문장은 낭독기 쪽으로만 보낸다
position: absolute;
width: 1px; height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
// ── 자동 루프 (2s) ────────────────────────────────────
// 0~24% 첫 두 줄만 보이는 상태 → 26% 버튼 눌림 → 30~50% 줄 묶음이 이메일 칸까지 올라간다
// → 40% 굵은 링이 켜짐(컷, 움직이는 도중에 켜야 대표 프레임에 링이 남는다) → 74% 링 끔 · 88% 제자리.
// transform 은 스크롤 상자가 아니라 track 에만 건다 — 상자에 걸면 100% 가 상자 자기 폭 기준이라 내용이 통째로 날아간다.
.fe.is-demo .fe__track {
animation-name: fe-scroll;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.fe.is-demo .fe__slot[data-filled="0"] {
transition: none;
animation-name: fe-ring;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.fe.is-demo .fe__btn {
transition: none;
animation-name: fe-btn;
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
@keyframes fe-scroll {
0% { transform: translateY(0); }
30% { transform: translateY(0); }
50% { transform: translateY(-40px); }
76% { transform: translateY(-40px); }
88% { transform: translateY(0); }
100% { transform: translateY(0); }
}
@keyframes fe-ring {
0% { outline-color: transparent; }
40% { outline-color: $color; }
74% { outline-color: transparent; }
100% { outline-color: transparent; }
}
@keyframes fe-btn {
0% { transform: scale(1); }
24% { transform: scale(1); }
26% { transform: scale(.97); }
30% { transform: scale(1); }
100% { transform: scale(1); }
}
// 320×200 — 무대 여백을 뺀 174px 안에 들어가도록 간격을 내린다(올리지 않는다)
@media (max-width: 360px) {
.fe { gap: $sp-1; }
.fe__viewport { height: 88px; }
.fe__cap { width: 44px; }
.fe__slot { height: 28px; line-height: 24px; }
.fe__btn { height: 32px; line-height: 32px; }
}
@media (prefers-reduced-motion: reduce) {
.fe.is-demo .fe__track,
.fe.is-demo .fe__slot[data-filled="0"],
.fe.is-demo .fe__btn { animation: none !important; }
.fe.is-demo .fe__track { transform: translateY(-40px); }
.fe.is-demo .fe__slot[data-filled="0"] { outline: 3px solid $color; outline-offset: 2px; }
.fe__slot, .fe__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fe {
box-sizing: border-box;
width: min(300px, 86vw);
display: flex;
flex-direction: column;
gap: 8px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.fe__viewport {
position: relative;
height: 96px;
overflow-y: auto;
border-radius: 16px;
background: rgba(255, 255, 255, 0.05);
}
.fe__track {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
}
.fe__row {
display: flex;
align-items: center;
gap: 8px;
}
.fe__cap {
flex: 0 0 auto;
width: 52px;
color: rgba(255, 255, 255, 0.5);
font-size: 11px;
line-height: 14px;
font-weight: 700;
}
.fe__slot {
box-sizing: border-box;
flex: 1 1 auto;
height: 32px;
padding: 0 12px;
border: 2px solid rgba(255, 255, 255, 0.14);
border-radius: 12px;
color: #fff7e6;
font-size: 13px;
line-height: 28px;
font-weight: 700;
outline: 0 solid transparent;
transition: outline-color 220ms ease-out, border-color 220ms ease-out;
}
.fe__slot[data-filled="0"] {
border-color: #c2361a;
outline: 3px solid transparent;
outline-offset: 2px;
}
.fe__slot.is-hit,
.fe__slot:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.fe__btn {
height: 34px;
border: 0;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
font-size: 13px;
line-height: 34px;
font-weight: 700;
cursor: pointer;
transition: transform 140ms ease-out;
}
.fe__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.fe__btn:active {
transform: scale(0.97);
}
.fe__say {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.fe.is-demo .fe__track {
animation-name: fe-scroll;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.fe.is-demo .fe__slot[data-filled="0"] {
transition: none;
animation-name: fe-ring;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.fe.is-demo .fe__btn {
transition: none;
animation-name: fe-btn;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes fe-scroll {
0% {
transform: translateY(0);
}
30% {
transform: translateY(0);
}
50% {
transform: translateY(-40px);
}
76% {
transform: translateY(-40px);
}
88% {
transform: translateY(0);
}
100% {
transform: translateY(0);
}
}
@keyframes fe-ring {
0% {
outline-color: transparent;
}
40% {
outline-color: #2f6df6;
}
74% {
outline-color: transparent;
}
100% {
outline-color: transparent;
}
}
@keyframes fe-btn {
0% {
transform: scale(1);
}
24% {
transform: scale(1);
}
26% {
transform: scale(0.97);
}
30% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
@media (max-width: 360px) {
.fe {
gap: 4px;
}
.fe__viewport {
height: 88px;
}
.fe__cap {
width: 44px;
}
.fe__slot {
height: 28px;
line-height: 24px;
}
.fe__btn {
height: 32px;
line-height: 32px;
}
}
@media (prefers-reduced-motion: reduce) {
.fe.is-demo .fe__track,
.fe.is-demo .fe__slot[data-filled="0"],
.fe.is-demo .fe__btn {
animation: none !important;
}
.fe.is-demo .fe__track {
transform: translateY(-40px);
}
.fe.is-demo .fe__slot[data-filled="0"] {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.fe__slot, .fe__btn {
transition: none;
}
}// 실제 동작: 제출이 막히면 첫 오류 칸으로 스크롤 상자를 내리고, 화면이 튀지 않도록 포커스는 스크롤 없이 따로 준다.
var fe = document.getElementById('fe');
var feBad = document.getElementById('fe-bad');
var feSlots = [].slice.call(document.querySelectorAll('.fe__slot'));
fe.addEventListener('submit', function (e) {
e.preventDefault();
fe.classList.remove('is-demo'); // 사람이 손대면 자동 루프를 멈춘다
var first = null;
feSlots.forEach(function (s) { if (!first && s.getAttribute('data-filled') === '0') first = s; });
if (!first) return;
first.scrollIntoView({ behavior: 'smooth', block: 'center' });
first.classList.add('is-hit');
first.focus({ preventScroll: true }); // preventScroll 이 없으면 이 데모가 든 부모 페이지까지 끌려간다
feBad.classList.add('is-hit');
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FirstErrorScroll.module.scss";
export type ScrollRow = { caption: string; value: string };
export type FirstErrorScrollProps = {
rows?: ScrollRow[];
submitLabel?: string;
/** 자동 재생 루프 (갤러리용). 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const DEFAULT_ROWS: ScrollRow[] = [
{ caption: "Name", value: "Bitna Kim" },
{ caption: "Birth date", value: "1996-04-02" },
{ caption: "Email", value: "" },
{ caption: "Address", value: "Seongsu-dong 12-gil" },
];
export default function FirstErrorScroll({
rows = DEFAULT_ROWS,
submitLabel = "Submit form",
demo = true,
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: FirstErrorScrollProps) {
const [hit, setHit] = useState(-1);
const [live, setLive] = useState(demo);
// ambient useRef 는 항상 T | null 을 돌려준다 — 지역 별칭으로 한 번만 좁혀 쓴다
const slotsRef = useRef<(HTMLElement | null)[]>([]);
const slots: (HTMLElement | null)[] = slotsRef.current || [];
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const firstBad = rows.findIndex((r) => r.value.length === 0);
function onSubmit(e: { preventDefault: () => void }) {
e.preventDefault();
setLive(false);
if (firstBad < 0) return;
const el = slots[firstBad];
if (!el) return;
el.scrollIntoView({ behavior: "smooth", block: "center" });
// preventScroll 이 없으면 이 부품이 iframe 안에 있을 때 부모 페이지까지 끌려 내려간다
el.focus({ preventScroll: true });
setHit(firstBad);
}
return (
<form className={`${styles.root} ${live ? styles.isDemo : ""}`} style={vars} noValidate onSubmit={onSubmit}>
<div className={styles.viewport}>
<div className={styles.track}>
{rows.map((r, i) => (
<div key={r.caption} className={styles.row}>
<span className={styles.cap}>{r.caption}</span>
<span
ref={(el: HTMLElement | null) => { slots[i] = el; }}
className={`${styles.slot} ${hit === i ? styles.isHit : ""}`}
data-filled={r.value ? 1 : 0}
tabIndex={r.value ? undefined : 0}
aria-invalid={r.value ? undefined : true}
aria-describedby={r.value ? undefined : "fe-say"}
>
{r.value}
</span>
</div>
))}
</div>
</div>
<button className={styles.btn} type="submit">{submitLabel}</button>
<p className={styles.say} id="fe-say" role="status" aria-live="polite">
{hit < 0 ? "" : `Moved to the empty ${rows[hit].caption} field`}
</p>
</form>
);
}09 Focus ring that walks you to the first error — Measured area 13.372% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · 9 Form Validation UI Patterns — As You Type
The post this demo lives in
9 Form Validation UI Patterns — As You TypeForm validation UI is the layer that checks a typed value against a rule and reports the verdict on screen. All nine run See all demos in this post
If you like this effect - similar demos
Inputs08 Submit that sends you back to the step with a blank left9 Multi Step Form UI Patterns — Branch and Resume
Inputs06 Step Ring That Shakes and Tightens on Validation FailureStep Indicator UI: 9 Physics-Based Patterns
Inputs05 Payment method card that opens only the option you pickedCheckout Page Design — 9 Cart Patterns, No Library