[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"quiz-css-user-invalid-native-form-validation-timing":44,"search-suggestions":60,"quiz-article-css-user-invalid-native-form-validation-timing":106},[4,20,32],{"id":5,"slug":6,"name":7,"tagline":8,"description":9,"accentFrom":10,"accentTo":11,"icon":12,"defaultLocale":13,"locales":14,"features":16,"position":19},"019fe637-3d33-714b-b57f-23e163ffca0c","dev","Web Development","Read it. Run it. Prove it.","A post a day on modern web development — most with an editable playground and a quiz that explains every answer. Free, no account needed.","violet-500","cyan-400","◇","en",[13,15],"fa",{"courses":17,"paths":17,"articles":17,"exams":18,"flashcards":18,"packages":17,"community":17,"certificates":17,"teams":17,"commerce":17},true,false,0,{"id":21,"slug":22,"name":23,"tagline":24,"description":25,"accentFrom":26,"accentTo":10,"icon":27,"defaultLocale":13,"locales":28,"features":30,"position":31},"019fe637-3dc2-754c-8657-0f175bfee7c6","lang","Languages","Learn a language the way you learn a codebase.","Grammar explained the way good documentation explains an API — one idea at a time, each with a quiz.","amber-400","⌘",[13,15,29],"es",{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":17,"packages":18,"community":17,"certificates":17,"teams":18,"commerce":18},2,{"id":33,"slug":34,"name":35,"tagline":36,"description":37,"accentFrom":38,"accentTo":39,"icon":40,"defaultLocale":13,"locales":41,"features":42,"position":43},"7b3c16f2-931d-410e-802e-e1fa4edab7de","soft","Soft Skills","The half of the job nobody wrote documentation for.","Weekly, on the parts of working life that decide more than your code does — first weeks, meetings, interviews, promotions, and the people around you. Written from what actually happens, and recorded as a podcast you can listen to on the walk.","emerald-400","teal-300","◉",[13],{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":18,"packages":18,"community":17,"certificates":18,"teams":18,"commerce":18},3,{"id":45,"slug":46,"kind":47,"title":48,"description":49,"config":50,"verticalId":5,"vertical":55,"course":52,"_count":56,"access":57,"attempts":59,"questionCount":51},"01a066ea-2982-736e-b702-e51a79bd35f9","css-user-invalid-native-form-validation-timing","PRACTICE_QUIZ",":user-invalid vs :invalid — form validation timing","Check what you took from the post: why plain :invalid fires before a user does anything, what actually counts as \"interaction\" for :user-invalid and :user-valid, and why a form reset wipes the styling but not the value.",{"questionCount":51,"timeLimitSec":52,"shuffleQuestions":18,"shuffleOptions":17,"negativeMarking":19,"passScorePct":53,"maxAttempts":52,"revealAnswers":54,"allowFlagging":18,"allowBacktracking":17},7,null,70,"AFTER_SUBMIT",{"slug":6,"name":7},{"questions":51},{"allowed":17,"reason":58},"FREE",[],[61,65,68,72,76,80,84,88,92,96,99,103],{"slug":62,"name":63,"articles":64},"webdev","Webdev",86,{"slug":66,"name":67,"articles":53},"javascript","Javascript",{"slug":69,"name":70,"articles":71},"frontend","Frontend",69,{"slug":73,"name":74,"articles":75},"css","Css",31,{"slug":77,"name":78,"articles":79},"tutorial","Tutorial",19,{"slug":81,"name":82,"articles":83},"performance","Performance",12,{"slug":85,"name":86,"articles":87},"typescript","Typescript",11,{"slug":89,"name":90,"articles":91},"react","React",10,{"slug":93,"name":94,"articles":95},"browser","Browser",6,{"slug":97,"name":98,"articles":95},"grammar","Grammar",{"slug":100,"name":101,"articles":102},"html","Html",5,{"slug":104,"name":105,"articles":102},"node","Node",{"id":107,"slug":46,"title":108,"subtitle":52,"excerpt":109,"coverUrl":110,"locale":13,"readingMinutes":102,"publishedAt":111,"viewCount":79,"likeCount":19,"commentCount":19,"author":112,"vertical":117,"topic":118,"tags":120,"_count":127,"playground":129,"body":131,"bodyMd":259,"seo":260,"translationGroupId":263,"series":52,"podcastUrl":52,"verticalId":5,"thread":264,"assessments":266,"translations":269,"quiz":271},"01a066ea-291a-77d8-aef4-d310318f61a9","Your Required Field Turns Red Before You Type Anything","A signup form's email input shows a red border the instant the page loads — before the visitor has typed a single character. The obvious CSS causes it, and the obvious fix is a native pseudo-class nobody reaches for first.","\u002Fmedia\u002Fcovers\u002Fcss-user-invalid-native-form-validation-timing.png","2026-09-08T10:56:30.073Z",{"id":113,"name":114,"username":115,"avatarUrl":52,"headline":116},"019fe637-3c25-7088-9034-39c9f15dc3c8","Parsa Jiravand","parsa","Frontend engineer · building bestpractic",{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":73,"name":119},"CSS",[121,122,125,126],{"slug":73,"name":74,"color":52},{"slug":123,"name":124,"color":52},"forms","Forms",{"slug":62,"name":63,"color":52},{"slug":69,"name":70,"color":52},{"assessments":128},1,{"slug":46,"title":130},":user-invalid vs :invalid — interactive playground",{"blocks":132,"version":128},[133,137,140,145,148,153,156,159,162,165,168,171,174,177,180,185,189,192,195,199,203,206,209,212,215,218,221,224,227,231,234,237,240,243,246,249,252],{"id":134,"html":135,"type":136},"b1","\u003Cp>Open a signup form, before you&#39;ve clicked into a single field. The email input already has a red outline. You haven&#39;t typed anything. You haven&#39;t done anything wrong. The page just loaded, and it&#39;s already telling you that you failed.\u003C\u002Fp>","paragraph",{"id":138,"html":139,"type":136},"b2","\u003Cp>Somebody wrote this:\u003C\u002Fp>",{"id":141,"code":142,"type":143,"language":73,"highlight":144},"b3","input:invalid {\n  border-color: #e5484d;\n}","code",[],{"id":146,"html":147,"type":136},"b4","\u003Cp>Reasonable-looking rule. Ships in every framework&#39;s forms tutorial. And it&#39;s the entire reason the field is red before the visitor exists to the page for more than a few milliseconds.\u003C\u002Fp>",{"id":149,"html":150,"text":151,"type":152,"level":31},"b5","Why \u003Ccode>:invalid\u003C\u002Fcode> fires before the user does anything","Why :invalid fires before the user does anything","heading",{"id":154,"html":155,"type":136},"b6","\u003Cp>\u003Ccode>:invalid\u003C\u002Fcode> isn&#39;t about whether the user made a mistake. It&#39;s about whether the \u003Cem>current value\u003C\u002Fem> fails constraint validation, full stop — checked continuously, starting from paint. An empty \u003Ccode>&lt;input required&gt;\u003C\u002Fcode> fails the &quot;has a value&quot; constraint the moment it renders, so \u003Ccode>:invalid\u003C\u002Fcode> matches immediately, no interaction required. Same for \u003Ccode>type=&quot;email&quot;\u003C\u002Fcode> with anything that isn&#39;t a valid address, \u003Ccode>pattern\u003C\u002Fcode> mismatches, \u003Ccode>minlength\u003C\u002Fcode> shortfalls — all of it evaluated from the first frame.\u003C\u002Fp>",{"id":157,"html":158,"type":136},"b7","\u003Cp>CSS is being completely honest here: the value genuinely doesn&#39;t satisfy the constraints. The bug isn&#39;t in the selector. It&#39;s in using &quot;is this value currently invalid&quot; as a proxy for &quot;should I show the user an error,&quot; when those are different questions until the user has actually tried.\u003C\u002Fp>",{"id":160,"html":161,"type":136},"b8","\u003C!-- playground:start -->",{"id":163,"html":164,"text":164,"type":152,"level":31},"b9","🎮 Try it yourself",{"id":166,"html":167,"type":136},"b10","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fcss-user-invalid-native-form-validation-timing\u002Fplayground\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":169,"html":170,"type":136},"b11","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":172,"html":173,"type":136},"b12","\u003C!-- playground:end -->",{"id":175,"html":176,"text":176,"type":152,"level":31},"b13","The fix people reach for is a JavaScript flag",{"id":178,"html":179,"type":136},"b14","\u003Cp>The instinct isn&#39;t to remove validation styling — the red border is genuinely useful once the user has had a shot at the field. It&#39;s to gate it on interaction. So teams build a &quot;touched&quot; flag:\u003C\u002Fp>",{"id":181,"code":182,"type":143,"language":183,"highlight":184},"b15","const [touched, setTouched] = useState(false);\n\n\u003Cinput\n  required\n  type=\"email\"\n  onBlur={() => setTouched(true)}\n  className={touched ? \"show-validity\" : \"\"}\n\u002F>","jsx",[],{"id":186,"code":187,"type":143,"language":73,"highlight":188},"b16","input.show-validity:invalid {\n  border-color: #e5484d;\n}",[],{"id":190,"html":191,"type":136},"b17","\u003Cp>This works. It&#39;s also why form libraries like Formik and React Hook Form ship a whole \u003Ccode>touched\u003C\u002Fcode> object mirroring every field, why every hand-rolled form has a scattering of \u003Ccode>onBlur\u003C\u002Fcode> handlers whose only job is flipping a boolean, and why that boolean has to be threaded through every input component in the tree. None of it is validating anything — the browser was already doing that part correctly. It&#39;s just gating \u003Cem>when to show it\u003C\u002Fem>, in JavaScript, because CSS supposedly couldn&#39;t ask &quot;has this user interacted with the field yet.&quot;\u003C\u002Fp>",{"id":193,"html":194,"type":136},"b18","\u003Cp>It can. It has been able to since 2021.\u003C\u002Fp>",{"id":196,"html":197,"text":198,"type":152,"level":31},"b19","\u003Ccode>:user-invalid\u003C\u002Fcode> and \u003Ccode>:user-valid\u003C\u002Fcode> ask the right question",":user-invalid and :user-valid ask the right question",{"id":200,"code":201,"type":143,"language":73,"highlight":202},"b20","input:user-invalid {\n  border-color: #e5484d;\n}\ninput:user-valid {\n  border-color: #30a46c;\n}",[],{"id":204,"html":205,"type":136},"b21","\u003Cp>Same styling, no \u003Ccode>touched\u003C\u002Fcode> state, no \u003Ccode>onBlur\u003C\u002Fcode>, no boolean threaded through props. \u003Ccode>:user-invalid\u003C\u002Fcode> matches a field that fails validation \u003Cem>and\u003C\u002Fem> that the user has interacted with; \u003Ccode>:user-valid\u003C\u002Fcode> is the mirror, for a field that passes. &quot;Interacted with&quot; means one of two things happened: the field committed an edit — you typed and then left it, the same moment that would fire a native \u003Ccode>change\u003C\u002Fcode> event — or the form had a submission attempted. Before either of those, neither pseudo-class matches, so the field sits neutral on page load exactly the way it should.\u003C\u002Fp>",{"id":207,"html":208,"type":136},"b22","\u003Cp>It shipped in Firefox first (Firefox 88, April 2021), then Safari (16.4, March 2023), then Chrome and Edge last (Chrome 119, November 2023) — so it&#39;s been in every evergreen browser for a while now, no polyfill, no library.\u003C\u002Fp>",{"id":210,"html":211,"type":136},"b23","\u003Cp>Type into that email field and leave it — tab away, or click elsewhere — and \u003Ccode>:user-invalid\u003C\u002Fcode> lights up if it&#39;s still malformed. That first blur is what unlocks it. Here&#39;s the part that&#39;s easy to miss: once it&#39;s unlocked, it keeps tracking live. Click back into the field and keep editing without blurring again, and the moment the value becomes a valid address, it flips straight to \u003Ccode>:user-valid\u003C\u002Fcode> — no second blur required. It only needed that one commit to start paying attention; after that, it&#39;s watching every keystroke, which is exactly what a hand-rolled \u003Ccode>touched\u003C\u002Fcode> flag usually fails to do, since most implementations set \u003Ccode>touched\u003C\u002Fcode> once on blur and then never revisit it.\u003C\u002Fp>",{"id":213,"html":214,"type":136},"b24","\u003C!-- quiz:start -->",{"id":216,"html":217,"text":217,"type":152,"level":31},"b25","🧠 Test yourself",{"id":219,"html":220,"type":136},"b26","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fcss-user-invalid-native-form-validation-timing\u002Fquiz\">Take the 7-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":222,"html":223,"type":136},"b27","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":225,"html":226,"type":136},"b28","\u003C!-- quiz:end -->",{"id":228,"html":229,"text":230,"type":152,"level":31},"b29","The gotcha: what &quot;reset&quot; actually resets","The gotcha: what \"reset\" actually resets",{"id":232,"html":233,"type":136},"b30","\u003Cp>Try this in the playground above: get a field into its red \u003Ccode>:user-invalid\u003C\u002Fcode> state, then click the form&#39;s reset button. The naive expectation is that resetting clears the value back to empty — and an empty required field is still invalid, so surely it should still be red?\u003C\u002Fp>",{"id":235,"html":236,"type":136},"b31","\u003Cp>It isn&#39;t. \u003Ccode>:user-invalid\u003C\u002Fcode> and \u003Ccode>:user-valid\u003C\u002Fcode> both stop matching the moment the form resets, because resetting the form clears the browser&#39;s internal &quot;has this user interacted with this control&quot; flag along with the value. The field goes back to a clean, neutral slate — not because the \u003Cem>value\u003C\u002Fem> became valid, but because the interaction that earned the styling got wiped too. A \u003Ccode>touched\u003C\u002Fcode>-flag implementation has to remember to do this by hand (reset the boolean in the same handler that resets the form state); the native pseudo-classes do it automatically, because resetting is specified to undo exactly the thing that made them match in the first place.\u003C\u002Fp>",{"id":238,"html":239,"type":136},"b32","\u003Cp>It&#39;s a small detail, and it&#39;s exactly the kind of thing that&#39;s easy to get right by accident with a library&#39;s abstraction and easy to get wrong by hand — which is a good sign you&#39;re better off leaning on the platform than re-implementing its bookkeeping.\u003C\u002Fp>",{"id":241,"html":242,"type":136},"b33","\u003Cp>Go find the \u003Ccode>:invalid { border-color: red; }\u003C\u002Fcode> line in your own forms, or the \u003Ccode>touched\u003C\u002Fcode> object doing the same job through a chain of \u003Ccode>onBlur\u003C\u002Fcode> handlers, and try swapping in \u003Ccode>:user-invalid\u003C\u002Fcode> \u002F \u003Ccode>:user-valid\u003C\u002Fcode> instead. What&#39;s the ugliest &quot;red on page load&quot; form you&#39;ve had to fix?\u003C\u002Fp>",{"id":244,"type":245},"b34","divider",{"id":247,"html":248,"type":136},"b35","\u003Cp>🚀 \u003Cstrong>Want more like this?\u003C\u002Fstrong> Every guide, playground, and quiz lives on \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">bestpractic.org\u003C\u002Fa>\u003C\u002Fstrong> — open it and \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">sign up free\u003C\u002Fa>\u003C\u002Fstrong> so the next one finds you.\u003C\u002Fp>",{"id":250,"html":251,"type":136},"b36","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":253,"type":254,"items":255,"ordered":18},"b37","list",[256,257,258],"⭐ \u003Cstrong>GitHub\u003C\u002Fstrong> — follow me and star the projects: \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fparsajiravand\">github.com\u002Fparsajiravand\u003C\u002Fa>","💬 \u003Cstrong>Discord\u003C\u002Fstrong> — join the frontend best-practices community: \u003Ca href=\"https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ\">discord.gg\u002Fd9KRhuAwQ\u003C\u002Fa>","📸 \u003Cstrong>Instagram\u003C\u002Fstrong> — frontend best practices, daily: \u003Ca href=\"https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F\">@bestpractice___\u003C\u002Fa>","Open a signup form, before you've clicked into a single field. The email input already has a red outline. You haven't typed anything. You haven't done anything wrong. The page just loaded, and it's already telling you that you failed.\n\nSomebody wrote this:\n\n```css\ninput:invalid {\n  border-color: #e5484d;\n}\n```\n\nReasonable-looking rule. Ships in every framework's forms tutorial. And it's the entire reason the field is red before the visitor exists to the page for more than a few milliseconds.\n\n## Why `:invalid` fires before the user does anything\n\n`:invalid` isn't about whether the user made a mistake. It's about whether the *current value* fails constraint validation, full stop — checked continuously, starting from paint. An empty `\u003Cinput required>` fails the \"has a value\" constraint the moment it renders, so `:invalid` matches immediately, no interaction required. Same for `type=\"email\"` with anything that isn't a valid address, `pattern` mismatches, `minlength` shortfalls — all of it evaluated from the first frame.\n\nCSS is being completely honest here: the value genuinely doesn't satisfy the constraints. The bug isn't in the selector. It's in using \"is this value currently invalid\" as a proxy for \"should I show the user an error,\" when those are different questions until the user has actually tried.\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fcss-user-invalid-native-form-validation-timing\u002Fplayground)**\n\n_Runs right in your browser — poke at it and watch the concept react live._\n\n\u003C!-- playground:end -->\n\n## The fix people reach for is a JavaScript flag\n\nThe instinct isn't to remove validation styling — the red border is genuinely useful once the user has had a shot at the field. It's to gate it on interaction. So teams build a \"touched\" flag:\n\n```jsx\nconst [touched, setTouched] = useState(false);\n\n\u003Cinput\n  required\n  type=\"email\"\n  onBlur={() => setTouched(true)}\n  className={touched ? \"show-validity\" : \"\"}\n\u002F>\n```\n\n```css\ninput.show-validity:invalid {\n  border-color: #e5484d;\n}\n```\n\nThis works. It's also why form libraries like Formik and React Hook Form ship a whole `touched` object mirroring every field, why every hand-rolled form has a scattering of `onBlur` handlers whose only job is flipping a boolean, and why that boolean has to be threaded through every input component in the tree. None of it is validating anything — the browser was already doing that part correctly. It's just gating *when to show it*, in JavaScript, because CSS supposedly couldn't ask \"has this user interacted with the field yet.\"\n\nIt can. It has been able to since 2021.\n\n## `:user-invalid` and `:user-valid` ask the right question\n\n```css\ninput:user-invalid {\n  border-color: #e5484d;\n}\ninput:user-valid {\n  border-color: #30a46c;\n}\n```\n\nSame styling, no `touched` state, no `onBlur`, no boolean threaded through props. `:user-invalid` matches a field that fails validation *and* that the user has interacted with; `:user-valid` is the mirror, for a field that passes. \"Interacted with\" means one of two things happened: the field committed an edit — you typed and then left it, the same moment that would fire a native `change` event — or the form had a submission attempted. Before either of those, neither pseudo-class matches, so the field sits neutral on page load exactly the way it should.\n\nIt shipped in Firefox first (Firefox 88, April 2021), then Safari (16.4, March 2023), then Chrome and Edge last (Chrome 119, November 2023) — so it's been in every evergreen browser for a while now, no polyfill, no library.\n\nType into that email field and leave it — tab away, or click elsewhere — and `:user-invalid` lights up if it's still malformed. That first blur is what unlocks it. Here's the part that's easy to miss: once it's unlocked, it keeps tracking live. Click back into the field and keep editing without blurring again, and the moment the value becomes a valid address, it flips straight to `:user-valid` — no second blur required. It only needed that one commit to start paying attention; after that, it's watching every keystroke, which is exactly what a hand-rolled `touched` flag usually fails to do, since most implementations set `touched` once on blur and then never revisit it.\n\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 7-question quiz →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fcss-user-invalid-native-form-validation-timing\u002Fquiz)**\n\n_Instant feedback, a hint on every question, and an explanation for each answer — right or wrong._\n\n\u003C!-- quiz:end -->\n\n## The gotcha: what \"reset\" actually resets\n\nTry this in the playground above: get a field into its red `:user-invalid` state, then click the form's reset button. The naive expectation is that resetting clears the value back to empty — and an empty required field is still invalid, so surely it should still be red?\n\nIt isn't. `:user-invalid` and `:user-valid` both stop matching the moment the form resets, because resetting the form clears the browser's internal \"has this user interacted with this control\" flag along with the value. The field goes back to a clean, neutral slate — not because the *value* became valid, but because the interaction that earned the styling got wiped too. A `touched`-flag implementation has to remember to do this by hand (reset the boolean in the same handler that resets the form state); the native pseudo-classes do it automatically, because resetting is specified to undo exactly the thing that made them match in the first place.\n\nIt's a small detail, and it's exactly the kind of thing that's easy to get right by accident with a library's abstraction and easy to get wrong by hand — which is a good sign you're better off leaning on the platform than re-implementing its bookkeeping.\n\nGo find the `:invalid { border-color: red; }` line in your own forms, or the `touched` object doing the same job through a chain of `onBlur` handlers, and try swapping in `:user-invalid` \u002F `:user-valid` instead. What's the ugliest \"red on page load\" form you've had to fix?\n\n---\n\n🚀 **Want more like this?** Every guide, playground, and quiz lives on **[bestpractic.org](https:\u002F\u002Fbestpractic.org\u002F)** — open it and **[sign up free](https:\u002F\u002Fbestpractic.org\u002F)** so the next one finds you.\n\n*Thanks for reading! Let's stay connected:*\n\n- ⭐ **GitHub** — follow me and star the projects: [github.com\u002Fparsajiravand](https:\u002F\u002Fgithub.com\u002Fparsajiravand)\n- 💬 **Discord** — join the frontend best-practices community: [discord.gg\u002Fd9KRhuAwQ](https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ)\n- 📸 **Instagram** — frontend best practices, daily: [@bestpractice___](https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F)",{"title":108,"canonical":261,"description":262},"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fcss-user-invalid-native-form-validation-timing","A signup form's email input shows a red border the instant the page loads — before the visitor has typed a single character. The obvious CSS causes it, and the obvious fix is a nat","01a066ea-291c-7376-b4ce-5507b92b4376",{"id":265,"locked":18},"01a066ea-2960-7098-b444-f23787321f07",[267],{"id":45,"slug":46,"title":48,"_count":268},{"questions":51},[270],{"locale":13,"slug":46},{"id":45,"slug":46,"title":48,"_count":272,"questionCount":51},{"questions":51}]