<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[@a-dev]]></title><description><![CDATA[I write about real-world frontend work: the CSS behaviors, UX decisions, and TypeScript patterns that don't make it into the docs but make the difference in pro]]></description><link>https://a-dev.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>@a-dev</title><link>https://a-dev.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Wed, 09 Sep 2026 18:28:14 GMT</lastBuildDate><atom:link href="https://a-dev.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Choreography of validation: how to make your auth form seamless and usable]]></title><description><![CDATA[You know, for users the best login is no login at all. But we still need one, and the most popular is the email/password form. And honestly, most of them are a mess. They nag you too early or too late]]></description><link>https://a-dev.hashnode.dev/choreography-of-validation-how-to-make-your-auth-form-seamless-and-usable</link><guid isPermaLink="true">https://a-dev.hashnode.dev/choreography-of-validation-how-to-make-your-auth-form-seamless-and-usable</guid><category><![CDATA[React]]></category><category><![CDATA[tanstack]]></category><category><![CDATA[authentication]]></category><category><![CDATA[form validation]]></category><category><![CDATA[forms]]></category><dc:creator><![CDATA[beqdot]]></dc:creator><pubDate>Tue, 30 Jun 2026 13:11:27 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a4237236b554787202261ba/fe99d6cd-8c20-47f2-b902-d1bd8e4d2573.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>You know, for users the best login is no login at all. But we still need one, and the most popular is the email/password form. And honestly, most of them are a mess. They nag you too early or too late: red errors when you've already typed a perfectly good email - or before you've typed a single character. I've seen it a lot.</p>
<p>Here I want to share one way to make this better - a very opinionated one. I don't want to drift into the "philosophy of UI", so it's a concrete example on a concrete stack: React, TanStack Form, Zod, and e.g. Better Auth on the backend.</p>
<h2>Reward early, punish late</h2>
<p>This is the main principle of good validation UX: find the right moment to tell users they messed up. And the right moment to tell them they're back on track.</p>
<p>The most common mistake is being too eager. The form screams "Invalid email!" while you're still typing. That's how the browser's native <code>&lt;input type="email"&gt;</code> behaves, and how most libs do it too with <code>onChange</code>.</p>
<p>I almost always prefer onBlur. It's a fair moment to say: hey, this field has something invalid, please fix it. But the interesting part comes next - what happens while the user is fixing it?</p>
<p>Usually the error just sits there while they retype, and clears only on the next blur, or after they submit. For some libraries that's the default.</p>
<p>Another flavor: some forms show the error only when you click the button. Too late. Especially when the error has nothing to do with the backend and could be caught on the frontend. And it gets worse: you see the error, fix your input, and it stays until you click again. Frustrating, right?
Ok, enough complaining, let's fix it.</p>
<p>Demo: <a href="https://a-dev.github.io/probes/validation/">https://a-dev.github.io/probes/validation</a></p>
<h2>Three independent timelines</h2>
<p>What helped me was a mental model: "the error" isn't one thing. It has three separate lives: when it's born, when it's shown to the user, and when it dies. We control each one independently.</p>
<h2>The birth of an error</h2>
<p>The naive approach shows a field's error whenever the value is invalid. Too eager, like we said. Validating only on submit is too lazy. <code>onBlur</code> is a good compromise, but it has its own catch: the error is born the moment you leave the field, even if you typed nothing. Click in, click out, and you get an error for a field you never filled.</p>
<p>Back to our principle. "Punish late" - don't create an error until the user has actually typed something. "Reward early" - once it's born, kill it the moment they start typing again. It feels right: they're actively fixing things, so let's reward that.</p>
<p>TanStack Form 1.x lets us express exactly this with a single <a href="https://tanstack.com/form/latest/docs/framework/react/guides/dynamic-validation"><code>onDynamic</code></a> validator and a custom <code>validationLogic</code> method. Thanks to the team for it - powerful and flexible.</p>
<pre><code class="language-ts">// reward-early-validation.ts
// universal dynamic validation logic
import type { ValidationLogicFn } from "@tanstack/react-form";

export const rewardEarlyPunishLate: ValidationLogicFn = ({
  form,
  validators,
  event,
  runValidation,
}) =&gt; {
  // If the form is async, we need to use the async version of the dynamic validator
  const dynamicValidator = event.async ? validators?.onDynamicAsync : validators?.onDynamic;

  // Has the field that triggered this event already surfaced an error? Only then do we re-judge on every keystroke (so the fix is rewarded instantly)
  const fieldHasError =
    !!event.fieldName &amp;&amp; (form.getFieldMeta(event.fieldName)?.errors.length ?? 0) &gt; 0;

  const shouldValidate =
    event.type === "submit" || event.type === "blur" || (event.type === "change" &amp;&amp; fieldHasError);

  // NOTE: runValidation returns the validator array the form actually runs, and the caller consumes that return value — so we must `return` it, even though the type says `=&gt; void`
  return runValidation({
    validators:
      shouldValidate &amp;&amp; dynamicValidator ? [{ fn: dynamicValidator, cause: "dynamic" }] : [],
    form,
  });
};
</code></pre>
<p>And the form:</p>
<pre><code class="language-ts">// login-form.tsx
import { useForm } from "@tanstack/react-form";
import { z } from "zod";
import { rewardEarlyPunishLate } from "./reward-early-validation";

function buildLoginSchema() {
  return z.object({
    email: z.email("Enter a valid email address"),
    password: z.string().min(1, "Enter your password"),
  });
}

const form = useForm({
  defaultValues: { email: "", password: "" },
  validationLogic: rewardEarlyPunishLate,
  validators: {
    onDynamic: ({ value }) =&gt; toFieldErrors(value), // the single source of error truth
  },
  // ...
});
</code></pre>
<p>The validator delegates the checking to a helper that adapts Zod's output into the TanStack Form shape:</p>
<pre><code class="language-ts">function toFieldErrors(values: LoginValues) {
  const result = buildLoginSchema().safeParse(values);
  if (result.success) return undefined;

  // Zod 4's treeifyError gives us a nested tree; we pull the first message per field
  const tree = z.treeifyError(result.error);

  const fields = Object.fromEntries(
    Object.entries(tree.properties ?? {}).flatMap(([fieldName, fieldError]) =&gt; {
      const message = fieldError.errors[0];
      return message ? [[fieldName, message]] : [];
    }),
  );

  return Object.keys(fields).length &gt; 0 ? { fields } : undefined;
}
</code></pre>
<p>Why not two validators: the <code>onBlur</code> (to catch a field you focus and leave without typing, no <code>change</code> event fires there) and the <code>onChange</code> (to clear the error as you fix it)? It works, but with a nasty side effect: TanStack stores errors per trigger (<code>errorMap.onBlur</code>, <code>errorMap.onChange</code>, …) and flattens them into one <code>errors</code> array. Two validators returning the same message produce duplicates, and, worse, an <code>onChange</code> returning <code>undefined</code> can't clear an <code>onBlur</code> error, so field state and the rendered UI disagree.</p>
<p>Routing both blur and change-while-errored through one validator avoids all that. One trigger, one <code>errorMap</code> entry, no duplicates. And when the value becomes valid, the error clears immediately instead of just hiding.</p>
<p>Note: typing in field B never starts live-validating it just because field A is invalid, the <code>fieldHasError</code> check only applies to the field that triggered the event.</p>
<h2>Show the error</h2>
<p>The errors now live in <code>field.state.meta.errors</code>. Time to show them with a small hook.</p>
<pre><code class="language-ts">// use-field-display-errors.ts
export function useFieldDisplayErrors(field: AnyFieldApi) {
  const [editing, setEditing] = useState(false);

  // Show errors only once the user has left the field (isTouched) and is not currently re-editing it. Start typing again → hide until the next blur re-judges

  const errors =
    field.state.meta.isTouched &amp;&amp; !editing ? getErrorMessages(field.state.meta.errors) : [];

  return {
    errors,
    invalid: errors.length &gt; 0,
    markEditing: () =&gt; setEditing(true), // call from input onChange
    markSettled: () =&gt; setEditing(false), // call from input onBlur
  };
}
</code></pre>
<p>The field component wires the two callbacks into the input's native events:</p>
<pre><code class="language-tsx">// input-field.tsx
const { errors, invalid, markEditing, markSettled } = useFieldDisplayErrors(field);

&lt;Input
  value={field.state.value}
  onBlur={() =&gt; {
    markSettled();
    field.handleBlur();
  }}
  onChange={(e) =&gt; {
    markEditing();
    field.handleChange(e.target.value);
  }}
  invalid={invalid}
/&gt;;

{
  invalid &amp;&amp;
    errors.map((msg) =&gt; (
      &lt;Field.Error key={msg} match&gt;
        {msg}
      &lt;/Field.Error&gt;
    ));
}
</code></pre>
<p><code>isTouched</code> is doing real work here. Because <code>onDynamic</code> is a form-level validator, a blur runs validation for all fields, not just the blurred one. The <code>isTouched</code> check keeps the still-unvisited fields quiet until the user actually lands on them.</p>
<p>Yes, there's a trade-off: click into a field, click out empty, and you'll see an error. But hiding errors on empty fields is worse - a required field should tell you it's required.</p>
<p>The <code>!editing</code> half is the "calm while you fix it" touch: the message vanishes the instant you start typing and comes back (if still wrong) on the next blur.</p>
<p><code>getErrorMessages</code> just turns each entry (a string or <code>{ message }</code>) into display text. No de-duplication - the single-trigger design can't produce duplicates in the first place. (That would bite you with multiple validators emitting the same message, but we don't have those here).</p>
<h2>The death of an error</h2>
<p>Some things you can only check on the server. In our example the error comes from Better Auth. It's a verdict on one specific (email, password) pair. So the moment the user edits either field, it's stale, and we remove it.</p>
<pre><code class="language-ts">// use-login.ts
const [formError, setFormError] = useState&lt;LoginError | null&gt;(null);

const form = useForm({
  // ...
  listeners: {
    // The banner reflects a verdict on a credential combination. The moment the user edits either field, that verdict is stale — so clear it
    onChange: () =&gt; setFormError(null),
  },
  onSubmit: async ({ value }) =&gt; {
    setFormError(null);
    const email = value.email.trim();
    const { error } = await authClient.signIn.email({
      email,
      password: value.password,
      rememberMe: true,
    });

    if (error) {
      // I prefer not to show raw error messages from Better Auth or other auth libraries, because they can be too technical or expose too much information. Instead, we classify the error into a user-friendly message by the error code or status. For example, 400/401 errors should stay deliberately ambiguous so we never reveal which field was wrong
      setFormError(classifyLoginError(error, email));
      return;
    }
    await onNavigate(resolveSafeRedirect(redirectTarget));
  },
});
</code></pre>
<p>Same refrain as everywhere: the instant they type, the verdict is gone. Reward the fix.</p>
<h2>Finale</h2>
<p>Let's weigh the trade-offs.</p>
<p>Starting with cons:
The first one is about implementation. <code>validationLogic</code> doesn't feel fully baked yet - it still has <a href="https://github.com/TanStack/form/blob/main/packages/form-core/src/ValidationLogic.ts">todos about types</a>. It'll surely get polished, which also means the API can still change.</p>
<p>There's a small, intentional gap between state and screen. The <code>!editing</code> check hides the message while the user types, even though the error is still sitting in <code>field.state.meta.errors</code>. Deliberate, calmer UX, but anything reading error state directly during that window sees something the user doesn't.</p>
<p>A server error can vanish before it's even read, if a hurried user starts typing before the banner renders. Trade-off again: rewarding the fix is good, but a message that flashes and disappears is just confusing. Fix: add a short delay before clearing it.</p>
<p>Every run calls <code>buildLoginSchema().safeParse(...)</code>, rebuilding the schema each time. On purpose, but still. Trivial for a login form; for something bigger, you might want to cache it.</p>
<p>Only the first error per field shows up. It's a UX choice. Want all of them? Make <code>toFieldErrors</code> return every message.</p>
<p>And now pros:</p>
<p>It feels good. Really good. No nagging, no frustration, no confusion. The user is rewarded for fixing their input, and errors show up exactly when they're useful and vanish the moment they're not.</p>
<p>Clear separation of concerns. "When does an error exist?" (validationLogic), "should we show it now?" (display hook), "is the server verdict still true?" (listener). Three small, separately testable decisions. I like that.</p>
<p>A single <code>onDynamic</code> trigger means no duplicate messages and no drift between field state and screen. And it's reusable, the same <code>validationLogic</code> and display hook can drive any form.</p>
<p>Overall, I picked a very specific stack and solved it there, and as you can see, the cons are mostly about implementation, the pros mostly about UX. The idea ports to other stacks and libraries, though, and I hope it helps you make your auth forms (and not only auth forms) better.</p>
]]></content:encoded></item><item><title><![CDATA[CSS 'overscroll-behavior' rubber banding: the right color behind the page when you pull it]]></title><description><![CDATA[I think you all know the overscroll rubber-banding effect: when you scroll past the end of a page, or pull past the top, the content bounces back. It exists in all modern browsers, though each one has]]></description><link>https://a-dev.hashnode.dev/css-overscroll-behavior-rubber-banding-the-right-color-behind-the-page-when-you-pull-it</link><guid isPermaLink="true">https://a-dev.hashnode.dev/css-overscroll-behavior-rubber-banding-the-right-color-behind-the-page-when-you-pull-it</guid><category><![CDATA[CSS]]></category><category><![CDATA[UX]]></category><category><![CDATA[Browsers]]></category><category><![CDATA[scroll]]></category><dc:creator><![CDATA[beqdot]]></dc:creator><pubDate>Mon, 29 Jun 2026 09:47:12 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a4237236b554787202261ba/b8cf127d-c434-46f6-81f2-e34978dd8482.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I think you all know the overscroll rubber-banding effect: when you scroll past the end of a page, or pull past the top, the content bounces back. It exists in all modern browsers, though each one has its own idea of how far you can pull. Desktop Chrome has a surprisingly long pull - I managed to drag the page by ~70% of the window height. Firefox barely budges, around 10%. Safari, on macOS and mobile, sits comfortably in the middle at 40–50%.</p>
<p>Personally, I find this effect quite enjoyable - it adds a nice touch to the user experience. But it comes with one huge catch: there is no CSS property or API to fill the space 'behind' the content when it's pulled. Yes, you can set a background color on the body, but that only works if your background is one solid color, shared by the header and footer. Which, let's be honest, is not the case for most websites. Some sites just set <code>overscroll-behavior: none</code> and call it a day - but it's a shame to lose such a nice effect over this.</p>
<p>Building a custom pull effect with JS isn't always a good idea either, especially for a simple landing page that doesn't need any special pull actions (calling a server, loading more content, and so on).</p>
<p>Worth mentioning: there is a new proposal, <a href="https://open-ui.org/components/overscroll-actions.explainer/">Declarative Overscroll Actions</a>, in the early stages of development. It's not clear yet whether it will give us an easy way to color the overscroll area, but I can see the potential for it somewhere in the future.</p>
<p>So there I was, stuck with a pretty simple landing page with a light-to-dark gradient from header to footer. On my way to solving this problem I ran into a lot of 'stranger things', and here I want to share my small findings on how to make it work more or less right.</p>
<p>You can find <a href="https://a-dev.github.io/probes/overscroll/">a demo here</a>. The important <a href="https://github.com/a-dev/probes/blob/main/src/pages/overscroll/overscroll.css">CSS file is here</a>.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=xleJ73SoNfc">https://www.youtube.com/watch?v=xleJ73SoNfc</a></p>

<h2>Gradient background</h2>
<p>The first idea was to set a background on the body. Not a solid color, but a gradient. Here's the catch: a solid color on <code>&lt;html&gt;</code> or <code>&lt;body&gt;</code> fills the 'bounce' area; a <code>linear-gradient</code>, <code>radial-gradient</code> or <code>conic-gradient</code> does not.</p>
<p>But if you create a pseudo-element like <code>html::before</code> or <code>body::before</code> - it does. Including all types of gradients! With one very annoying issue: it has to be <code>position: fixed</code>. Absolute positioning won't do - the element is positioned relative to the body, so when you pull the page, it gets pulled along with the content.</p>
<p>Fixed positioning solves that, but creates a new problem: the element ignores scrolling completely. So if your gradient goes from light at the top to dark at the bottom, you can't continue the gradient into the overscroll area. At best, you can extend the gradient's outermost colors as solid fills: light at the start, dark at the end. It looks more or less good.</p>
<p>Here I need to stop and bring up one more aggravating circumstance: rubber banding happens on horizontal pulls too. And it often looks worse than the vertical one - designs tend to have differently colored sections, full-bleed photos, and so on. In a perfect world we might fill these areas with some kind of ambient-light effect, and even then I'm not sure it would fit. Honestly, I prefer to turn horizontal overscrolling off. It's a one-liner - <code>overscroll-behavior-x: none</code> on body/html - and no big loss for most websites. What's more, iOS Safari behaves this way by default.</p>
<p>I should stress that conic or radial gradients rarely make sense for overscroll areas anyway, since the pseudo-element is fixed and doesn't react to scroll at all. Here is the code for the gradient background (white to black, for example):</p>
<pre><code class="language-css">body::before {
  pointer-events: none;
  content: "";

  position: fixed;
  z-index: -1;
  inset: 0;

  width: 100%;
  height: 100%;

  background-image: linear-gradient(white 0%, white 50%, black 50%, black 100%);
  background-repeat: no-repeat;
}
</code></pre>
<p>The important part: I split the gradient at 50% not just for fun - but because the user can pull the page for fun! And this is the first obstacle on the road to perfection. Pull diligently enough in Chrome, and the page moves by 70% of the window height. From both sides! So this approach works - with a big BUT.</p>
<h2>Fixed elements</h2>
<p>Next, I tried fixed elements that live within the page borders and kind of continue the page into the void. Let's attach them to the header and footer. In reality, I hit the same wall as with the gradient background: absolute positioning is out (pulled along with the content), so they have to be fixed - and fixed elements ignore scroll. You end up splitting them into two halves, and if the user pulls the page by 70% of the window height... you know the drill.</p>
<pre><code class="language-css">.header::after,
.footer::after {
  content: "";
  position: fixed;
  z-index: -1;
  left: 0;
  width: 100dvw;
  height: 50dvh;
}

header::after {
  top: 0;
  background-color: var(--color-bg-light);
}

footer::after {
  bottom: 0;
  background-color: var(--color-bg-dark);
}
</code></pre>
<h2>Animation</h2>
<p>At this point I remembered that we have a <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/animation-timeline/scroll">new API for scroll-linked animations</a>. Yes, still early days, but it's already supported in Chrome and Safari (Firefox is on the way). So the idea: move those fixed elements together with the scroll, like this:</p>
<pre><code class="language-css">/* in addition to the previous code */
header::after,
footer::after {
  transform-origin: top;
  height: 100dvh;
  animation: scale-part;
  animation-timeline: scroll();
}

footer::after {
  animation-direction: alternate-reverse;
}

@keyframes scale-part {
  from {
    transform: scaleY(1);
  }

  to {
    transform: scaleY(0);
  }
}
</code></pre>
<p>Note that when the scroll reaches the end of the page, the animation grows the fixed footer block to 100dvh - so even a 70% pull won't break the illusion. Same for the header, but in reverse. Great! Except it doesn't work in Safari. Chrome only... so-so choice.</p>
<h2>Safari?</h2>
<p>The struggle with Safari led me to change the way the animation works. Instead of scaling a fixed element, let's animate the body background color itself. Why not? The user doesn't see this layer anyway - it sits behind the content and only peeks out at the outermost positions. Note: it’s crucial for Safari to have a background set on the body element, not the html. The CSS is delightfully simple:</p>
<pre><code class="language-css">body {
  animation: scroll-background;
  animation-timeline: scroll();
}

@keyframes scroll-background {
  from {
    background-color: white;
  }

  to {
    background-color: black;
  }
}
</code></pre>
<p>And it works! In Safari! And in Chrome! Firefox...</p>
<h2>Universal solution?</h2>
<p>I tried different approaches, and at some point I decided to give up on Firefox, for two reasons: it has a short pull and a small share of users (sorry, Firefox - I love you anyway and hope you'll support scroll animations soon). For Firefox we should at least keep a monotone background; better than nothing. I'd recommend the site's main background color, so the fill stays aligned with the design. The final solution:</p>
<pre><code class="language-css">html {
  animation: scroll-background;
  animation-timing-function: linear;
  animation-timeline: scroll();
  background-color: black;
}

@keyframes scroll-background {
  from {
    background-color: white;
  }

  to {
    background-color: black;
  }
}
</code></pre>
<p>And now, some interesting observations. Safari is not so simple. Apple loves to push the boundaries of design - literally. At the top of the desktop browser it shows a blurred version of the open page, and of course it uses the defined background color as a base. The animation there is smooth but a bit unpredictable - I suspect the system tries to find a contrasting color for this blurred layer, so the animation is not exactly linear. In mobile Safari, in contrast, the same animation happens at the bottom of the page and looks even more interesting.</p>
<p>Remember that you can control how the animation behaves - at which point of the scroll the colors change - and if you play with it, you can achieve some interesting effects in mobile Safari.</p>
<h2>A sober afterword: weaknesses, and what would actually fix this</h2>
<p>Time for some cold water. The final solution works, but it is a hack standing on another hack's shoulders and has a few weaknesses:</p>
<ul>
<li><p><strong>Repaint cost.</strong> Background color on the root is not a compositor-friendly property: every scroll frame triggers paint work on the main thread. The browser might optimize it in some way, since this layer is hidden behind the content, but that's not guaranteed. I checked the performance in Safari and Chrome and it seems fine, even on mobile - but keep it in mind. Scroll-linked animations are not free, especially when they trigger paint work. The fixed-elements approach is more efficient, since it only animates <code>transform</code>.</p>
</li>
<li><p><strong>Short pages break it.</strong> If the page doesn't scroll, <code>scroll()</code> has no timeline, the animation never runs, and the fallback <code>background-color: black</code> shows behind a light header. You need a guard for non-scrolling pages.</p>
</li>
<li><p><strong>It only handles vertical overscroll.</strong> Horizontal pulls are still a problem.</p>
</li>
<li><p><strong>The content must be fully opaque.</strong> Any transparent gap in the body lets the user watch the background morph from white to black mid-scroll. That one is fun to debug.</p>
</li>
</ul>
<p>How could it be solved properly? Honestly - only by the platform. Overscroll is drawn by the browser outside the document, so every CSS trick is, by definition, an illusion stitched to scroll position. <a href="https://open-ui.org/components/overscroll-actions.explainer/">Declarative Overscroll Actions</a> is the closest thing on the horizon, since it anchors real, styleable elements into the overscroll area; until something like it ships, a dedicated <code>overscroll</code>-background property remains wishful thinking.</p>
<p>And one last sobering thought: elastic overscroll effectively exists only on macOS and iOS. Windows, Linux and most Android users will never see any of this effort. But this is exactly the kind of detail 99% of visitors will never consciously notice, yet it's what makes a site feel hand-made and cared for. Small win, big smile. Happy overscrolling!</p>
]]></content:encoded></item></channel></rss>