We deleted a 308-line unlock dialog and replaced it with a scroll and three pulses

We deleted a 308-line unlock dialog and replaced it with a scroll and three pulses

Our practice dashboard lists a provider's games. If you have not bought that provider, there is a slim card above the grid offering the two things you can buy, and the locked cards below it used to open a modal when clicked. The modal was good. It had its own checkout state so that only the clicked button showed a spinner, its own discount-code field, a success state, and two purchase options with a feature list each. It was also selling exactly what the card 400 pixels above it was selling, and it could be opened by a deep link from the play wall, which is how you end up with a page where the same two products exist in two places that have to be kept in step. It is gone. ProviderGamesView.tsx lost 308 lines and gained 47, and the behaviour it lost was never the point: what somebody clicking a locked card wants is to be told how to unlock it, not to be given a second surface. Point at the thing instead of rebuilding it The whole replacement is a 48-line hook: export function useSpotlight() { const ref = useRef(null); const timer = useRef(undefined); const spotlight = useCallback(() => { const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const behavior: ScrollBehavior = reduceMotion ? 'auto' : 'smooth'; const card = ref.current; if (!card) { window.scrollTo({ top: 0, behavior }); return; } card.scrollIntoView({ behavior, block: 'start' }); const targets = card.querySelectorAll(`[${SPOTLIGHT_TARGET}]`); window.clearTimeout(timer.current); targets.forEach((el) => { el.classList.remove('spotlight-pulse'); // Force a reflow so re-adding the class restarts the animation. void el.offsetWidth; el.classList.add('spotlight-pulse'); }); timer.current = window.setTimeout(() => { targets.forEach((el) => el.classList.remove('spotlight-pulse')); }, PULSE_MS); }, []); return { ref, spotlight }; } Enter fullscreen mode Exit fullscreen mode Four things in there are worth stealing. void el.offsetWidth between removing and adding the class. Without it, a second click within the animation's lifetime does nothing visible, because the browser coalesces the remove and the add and never sees the class leave. Reading a layout property forces the style recalculation that makes it a real restart. It is the oldest trick in CSS animation and it still reads like a typo, so it gets a comment. The missing-card branch scrolls to the top of the page rather than doing nothing. The card is unmounted while a purchase is still confirming, and the status that replaces it is at the top, so "scroll to where the answer is" stays true in both states. Reduced motion changes the scroll too, not just the pulse. A smooth scroll is motion, and a long one is the kind of motion the preference exists for. And the duration is derived rather than typed twice: /** Three pulses after the scroll has settled, matching `.spotlight-pulse`. */ const PULSE_MS = 0.35 * 1000 + 0.9 * 1000 * 3; Enter fullscreen mode Exit fullscreen mode That is the CSS animation: spotlight-pulse 0.9s ease-out 0.35s 3 written as arithmetic. It is still two sources of truth, which I do not love, but an expression that visibly reconstructs the CSS from its parts is a lot easier to keep honest than 3050. The animation @keyframes spotlight-pulse { 0% { box-shadow: 0 0 0 0 color-mix(in oklab, var(--primary) 70%, transparent); transform: scale(1); } 35% { transform: scale(1.04); } 70% { box-shadow: 0 0 0 12px color-mix(in oklab, var(--primary) 0%, transparent); transform: scale(1); } 100% { box-shadow: 0 0 0 0 color-mix(in oklab, var(--primary) 0%, transparent); transform: scale(1); } } /* Reduced motion gets a steady ring for the same span instead of a pulse. */ @media (prefers-reduced-motion: reduce) { .spotlight-pulse { animation: none !important; box-shadow: 0 0 0 3px var(--primary); } } Enter fullscreen mode Exit fullscreen mode An expanding box-shadow ring with the colour faded to zero alpha, plus a 4 percent scale bump, which is small enough not to reflow anything around it. color-mix(in oklab, ...) on a theme variable means the ring is the right colour in both themes with no second declaration. The reduced-motion branch is the part I want to argue for. The easy version is animation: none, which leaves a user who asked for less motion with no answer at all to the click they just made: the page jumps and nothing is marked. A static 3px ring for the same duration carries the same information with no movement. Reduced motion is a request for less motion, not for less feedback, and the two are easy to confuse when you are deleting an animation. Two call sites, two ways to ask In the exercises dashboard, the locked card and the unlock card are in the same component, so the hook's function is passed straight down as onLockedClick={spotlight}. In the games dashboard it cannot be, because the trigger can arrive in the URL: the play wall links back here with a provider parameter, and the spotlight has to fire after mount rather than from a click. So the request goes through state: const { ref: unlockCardRef, spotlight } = useSpotlight(); const [spotlightRequest, setSpotlightRequest] = useState(0); useEffect(() => { if (spotlightRequest > 0) spotlight(); }, [spotlightRequest, spotlight]); Enter fullscreen mode Exit fullscreen mode A counter, not a boolean. A boolean goes true once and then needs resetting before it can fire again, which means either a second state write or an effect that cleans up after itself; an incrementing number is a new value every time, so the effect re-runs on every request and the "already spotlit once" case needs no handling at all. setSpotlightRequest((n) => n + 1) is the whole trigger, from the deep link and from a locked card alike. The locked card's handler does one other thing before it increments: it records the paywall event. That lives on the click rather than beside the spotlight, because a visit arriving on the ?unlock= link has already had a wall recorded on the screen that sent it, and a user holding an unspent provider token is not looking at a paywall at all. Same pulse, two different reasons for it, one of which is not a funnel step. The buttons that pulse are marked with a data attribute held in a constant, which produces the one bit of JSX in this change that needs explaining: unlockWithToken(selectedProvider)} > Enter fullscreen mode Exit fullscreen mode JSX has no syntax for an attribute whose name comes from a variable, so a computed-key spread is the way to keep the attribute name in one place rather than typing data-spotlight in the hook and again at three call sites. One Tailwind class carries more weight than it looks: scroll-mt-20 on the card. Our header is 56px and fixed, so scrollIntoView({ block: 'start' }) without a scroll margin parks the card neatly underneath it. While we were removing places that sell things A few hours later, the dashboard header lost its plan tier as well. The profile dropdown had an "Upgrade" item rendered only for users who had not bought scores and feedback, and planTier was threaded from the layout through DashboardHeader into DashboardHeaderProfileDropdown to decide it. That is three components knowing about a plan to render one menu item, and the item duplicated a card the dashboard already shows. - {!isPremium && ( - - - - Upgrade - - - )} Enter fullscreen mode Exit fullscreen mode Nineteen deleted lines, two props, one icon import. The layout still computes planTier, because analytics identifies on it, so nothing got cheaper. It got simpler, which is a different and better thing: there is now one place on each page where a purchase is offered, and when the copy on it changes there is nowhere else to update. See it The hook and the card are behind sign-in, and sign-up is free and two fields: cogniprep.app/signup. Once you are in, open any provider you have not bought from cogniprep.app/games, click a locked game, and the page scrolls back to the card at the top and rings its buttons three times. Turn on reduced motion in your OS and do it again: the scroll is instant and the ring is steady. If you would rather not sign up, the CSS is public, because it is in the one global stylesheet every page loads. View source on cogniprep.app, follow the .css link under /_next/static/chunks/, and search it for spotlight-pulse. You get four matches in about 280KB: the keyframes, the class, and the reduced-motion override, all shipped to a marketing page that will never use them, which is what one global stylesheet costs you.

Original Source

Read the full article at Dev →

KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.