Our welcome screen is covered by a CSS rule that deletes itself after three seconds
Signing in used to drop you straight onto a dashboard, or onto the game you were trying to play, with no acknowledgement that anything had happened. We wanted a short full-screen welcome: logo, a line of text, a progress bar, gone in a couple of seconds.
The easy version of this is a /welcome page you redirect through. We did not want that, because the redirect we already perform is doing real work. It sends people to the thing they were trying to do before they were asked to sign in, and inserting an interstitial page into that chain means a second navigation, a second chance to lose the destination, and a URL in the history that nobody wants to go back to.
So the welcome plays over the destination instead, and the whole mechanism is one cookie.
A cookie that is read once and then is gone
export const WELCOME_COOKIE = 'cp_welcome'; /** `new` for an account created just now, `back` for a returning sign-in. */ export type WelcomeKind = 'new' | 'back'; /** Long enough to survive the redirect, short enough never to surface later. */ export const WELCOME_COOKIE_OPTIONS = { path: '/', maxAge: 120, sameSite: 'lax' as const, secure: process.env.NODE_ENV === 'production', httpOnly: false, }; Two minutes is the interesting number. It only has to live across one redirect, so anything longer is a liability: a cookie that survives an hour is a welcome screen that plays on a page you opened an hour later because the tab that should have consumed it crashed. The overlay clears it the moment it reads it, and the 120 seconds are there for the case where nothing reads it at all.
Three paths set it. The email sign-up sets new, the email sign-in sets back, and the OAuth callback picks between them using a lookup it was already doing:
const existingUser = await getUserByEmail(user.email!); // An account created by this callback is greeted as new, anyone else as returning. const welcome: WelcomeKind = existingUser ? 'back' : 'new'; That is the whole "is this person new" question answered for free, because the callback has to know anyway in order to decide whether to create the account row.
Reading it on every pathname change, not on mount
The overlay sits in the root layout, and the obvious useEffect(..., []) is wrong:
useLayoutEffect(() => { const next = takeWelcome(); if (next) { setKind(next); setOpen(true); } document.documentElement.classList.remove('welcome-pending'); }, [pathname]); The email sign-in and sign-up are server actions that call redirect(), and that is a client-side navigation: the root layout is never unmounted, so a mount-only effect runs before the cookie exists and never again. Keyed on pathname, it runs on arrival. The OAuth callback is a full page load, so it would have worked either way, and the two paths needing different answers is exactly the sort of thing that only shows up when you test both.
takeWelcome reads and deletes in one go, which is what makes it one-shot rather than "one per page view":
function takeWelcome(): WelcomeKind | null { const prefix = `${WELCOME_COOKIE}=`; const entry = document.cookie.split('; ').find((part) => part.startsWith(prefix)); if (!entry) return null; document.cookie = `${WELCOME_COOKIE}=; path=/; max-age=0; samesite=lax`; const value = entry.slice(prefix.length); return value === 'new' || value === 'back' ? value : null; } Note the last line. Two valid values, and anything else returns null rather than falling through to a default, because a cookie is user-editable text and "greet an unknown third kind of person" is not a state the component has copy for.
The flash, and the cover that has to outlive its own JavaScript
On a full page load there is a window between first paint and React mounting. During that window the destination page is visible, so the sequence is: dashboard, then welcome screen, then dashboard again. Worse than no welcome at all.
Covering it needs something that is already in effect at first paint, which means the blocking inline script we already have in the document head for theme resolution. It gained four lines:
// A sign-in that set the welcome cookie: cover the page until the welcome // overlay mounts over it, so the destination never flashes first. if (document.cookie.indexOf("cp_welcome=") !== -1) { root.classList.add('welcome-pending'); } And the class is a full-page cover in CSS. This is the part I like:
html.welcome-pending::after { content: ''; position: fixed; inset: 0; z-index: 99; background: var(--background); animation: welcome-cover-timeout 0s linear 3s forwards; } @keyframes welcome-cover-timeout { to { visibility: hidden; } } A zero-duration animation with a three-second delay and forwards is a pure-CSS timeout. The cover is painted immediately, and three seconds later it hides itself whatever else has happened.
That matters because of what the cover is protecting against. It hides a flash caused by JavaScript not having run yet, so the one failure mode it absolutely must survive is JavaScript never running: a chunk that 404s after a deploy, a blocked script, an error thrown in an earlier component in the tree. A setTimeout cannot save you there, because the timer lives in the thing that failed. CSS keeps running when the bundle does not.
The overlay removes the class unconditionally, even when there is no cookie to read, so the two mechanisms cannot disagree about who is responsible for taking the cover down.
The bits that are just a nice animation
The exit is a closing iris, which is cheap and reads as the page being revealed rather than the overlay leaving:
exit={ reduceMotion ? { opacity: 0, transition: { duration: 0.3 } } : { clipPath: 'circle(0% at 50% 50%)', transition: { duration: 0.75, ease: [0.65, 0, 0.35, 1] }, } } Reduced motion gets a fade, and also skips the twelve sparks thrown out from the logo, the per-word blur-in on the heading and the ring that draws itself. reduceMotion comes from framer-motion's own useReducedMotion, which, when I audited this codebase three weeks ago, had zero callers across the twelve files that import framer-motion. This is the first one. The progress bar still animates in both modes, because it is not decoration: it is the only thing telling you how long the screen you cannot dismiss by waiting will last. You can also dismiss it by clicking anywhere or pressing any key, and the copy says so, with different wording on phones because "press any key" is not advice you can take on a phone.
Two durations, new 3400ms and back 2200ms, and the progress bar reads the same constant, so it cannot drift from the timer it is drawing.
See it
The cover is checkable without signing in, because the inline script is serialised into the HTML of every page:
curl -s https://cogniprep.app/ | grep -o "cp_welcome=.\{0,60\}" You get the cookie test and the classList.add('welcome-pending') that follows it.
The CSS is there too. Our one global stylesheet is about 280KB served from /_next/static/chunks/, and it contains the welcome-pending:after rule and the @keyframes welcome-cover-timeout{to{visibility:hidden}} above, minified to within a few characters of what I pasted.
For the animation itself you have to sign in, which is free: cogniprep.app/signup takes an email address and a password and nothing else. Make an account and you get "Thanks for signing up". Sign out and back in and you get "Welcome back", over whatever page you asked for, which is the point.