DOC# ZERAME SLUG zera_med_responsive_hud PRINTED 2026-05-06 03:47 UTC

A Privacy Demo That Works on a Phone: Mobile Drawer, HUD Offsets, and Real Breach Data

Bolting a mobile drawer onto the Zera Med ZK-FHIR demo without breaking the desktop sidebar, fixing AnimatePresence warnings, and updating PrivacyChallenge with 2024-2025 breach data.

FROM
Dax the Dev <[email protected]>
SOURCE
https://blog.skill-issue.dev/blog/zera_med_responsive_hud/
FILED
2026-02-11 22:48 UTC
REVISED
2026-02-11 22:48 UTC
TIME
12 min read
SERIES
Zera Med Devlog
TAGS
#zera-med #react #tailwind #responsive #accessibility #framer-motion #demo

The unspoken rule of demo apps is that they’re built for laptops. You’d never demo a healthcare privacy product from a phone. You’d plug the laptop into a projector and run it from a 13” screen. Real users wouldn’t be on a phone, the dataset has columns that don’t fit on mobile, and you’ve shipped a desktop-only experience without thinking about it.

But every demo I’ve done in 2026 has had at least one person in the room pulling up the URL on their phone while I’m presenting. They’re checking the responsive design. They’re clicking around in the half-attention you’d give a panel discussion. If the phone experience falls apart, that person walks away with the impression that the product falls apart, regardless of how clean the laptop view is.

bb9bb51 — Add responsive layout with mobile drawer, centered content, and accuracy updates on 2026-02-11 was the day I bolted on real mobile support. Six files changed, +4969 lines, three new pages. Let’s look at what mattered.

The mobile drawer pattern

The desktop nav is a fixed left sidebar. The mobile nav is a hamburger that slides a drawer in from the left. The trick is doing both with the same component tree:

// Sidebar.tsx (excerpt)
const isMobile = useMediaQuery('(max-width: 1023px)')
const [drawerOpen, setDrawerOpen] = useState(false)

return (
  <>
    {/* Mobile header — only on small screens */}
    {isMobile && (
      <header className="fixed top-0 left-0 right-0 h-14 z-40 ...">
        <button onClick={() => setDrawerOpen(true)}></button>
        <span className="brand">Zera Med</span>
        <RoleBadge role={role} />
      </header>
    )}

    {/* Sidebar — fixed left on desktop, slide-in drawer on mobile */}
    <AnimatePresence>
      {(!isMobile || drawerOpen) && (
        <motion.aside
          initial={isMobile ? { x: -300 } : false}
          animate={{ x: 0 }}
          exit={isMobile ? { x: -300 } : undefined}
          transition={{ type: 'spring', damping: 24 }}
          className="..."
        >
          {/* nav links */}
        </motion.aside>
      )}
    </AnimatePresence>

    {/* Backdrop — only when drawer is open on mobile */}
    {isMobile && drawerOpen && (
      <motion.div
        className="fixed inset-0 bg-black/60 z-30"
        onClick={() => setDrawerOpen(false)}
        initial={{ opacity: 0 }}
        animate={{ opacity: 1 }}
        exit={{ opacity: 0 }}
      />
    )}
  </>
)

Three things to call out:

useMediaQuery — not just window.innerWidth. I added a tiny hook in this commit:

// useMediaQuery.ts
export function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState(() =>
    typeof window !== 'undefined' && window.matchMedia(query).matches
  )
  useEffect(() => {
    const mq = window.matchMedia(query)
    const onChange = (e: MediaQueryListEvent) => setMatches(e.matches)
    mq.addEventListener('change', onChange)
    return () => mq.removeEventListener('change', onChange)
  }, [query])
  return matches
}

The reason window.innerWidth is wrong: it doesn’t subscribe to changes. You’d need a manual resize listener with debouncing. matchMedia with addEventListener('change') is the platform-native way and it’s both faster (no JS resize event spam during drag-resize) and less code.

{(!isMobile || drawerOpen) && ...}. The mount/unmount logic. On desktop, the sidebar is always present. On mobile, it’s only present when the drawer is open. This is what AnimatePresence needs to wrap correctly — the component literally unmounts when the drawer closes, which triggers the slide-out exit animation.

Body scroll lock. Not in the snippet but in the full diff: when the drawer is open on mobile, document.body.style.overflow = 'hidden' to prevent the underlying page from scrolling under the drawer. Without this, the drawer is open, the user starts scrolling, and the page behind the drawer scrolls instead of the drawer’s contents. UX bug from hell.

Sticky HUDs and the mobile-header offset

The Zera Med demo has “HUD panels” that stick to the top of the page on each route — they show the current role (Patient/Doctor/Insurer/etc.) and a quick action menu. On desktop, they sit at top: 0. On mobile, the page has a 56px header at top: 0 already, so the HUDs need to slide down by 56px:

<div className="sticky top-14 lg:top-0 z-20 ...">
  <RoleBadge />
  <QuickActions />
</div>

Tailwind’s top-14 is 3.5rem = 56px. lg:top-0 overrides for lg+ viewports where the mobile header isn’t rendered. Two utility classes, exactly the right offset, no media-query logic in the component.

This is the kind of thing that’s easy to miss until the demo opens on a phone and the HUD is hidden behind the mobile header. Then you spend ten minutes debugging because everything looks fine in dev tools’ “responsive” mode, where the mobile header is shown but the layout is otherwise desktop. The fix is one className. Finding the bug is the project.

Tight grids that collapse gracefully

The dashboards have grids like grid-cols-4 and grid-cols-6 for layouts of metric cards. On a 320px-wide phone, four cards across is 80px each, which is unreadable. The solution is per-breakpoint cols:

<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
  {metrics.map(m => <MetricCard {...m} />)}
</div>

This is the standard Tailwind approach — it’s not novel — but applying it to every grid in the demo took a careful pass. Some grids in the original were grid-cols-4 (no breakpoint prefix), which forced four-across on every viewport. The diff replaced 12 such grids with breakpoint-aware variants.

The mental model I use: grid-cols-N should always have a <breakpoint>:grid-cols-K partner unless you’ve intentionally decided “this layout is mobile-only” or “this layout never goes below 4 across.” The default of “this works on 1280px-wide screens and breaks below” is the desktop-blinkered version of the same component.

Fixing the AnimatePresence warning

Warning: Each child in a list should have a unique "key" prop.
Or alternatively when using AnimatePresence: AnimatePresence requires every child to have a unique `key` prop, even when only one child is rendered.

Anyone who’s used Framer Motion has seen this. The PrivacyChallenge component had this exact bug — a single conditionally-rendered <motion.div> inside <AnimatePresence> with no key prop. The fix:

<AnimatePresence mode="wait">
  {currentLab && (
    <motion.div
      key={currentLab.id}                  // ← was missing
      initial={{ opacity: 0, y: 24 }}
      animate={{ opacity: 1, y: 0 }}
      exit={{ opacity: 0, y: -24 }}
    >
      <LabContent {...currentLab} />
    </motion.div>
  )}
</AnimatePresence>

The key={currentLab.id} is what tells AnimatePresence that “this is a different element when currentLab.id changes,” and triggers the exit animation of the old one and the enter animation of the new one. Without the key, Framer Motion sees the same element with new props and skips the exit/enter cycle. The result is content swapping with no transition, plus the warning in console.

mode="wait" is the other half: it tells Framer to wait for the exit animation to complete before mounting the next child. Without it, exit and enter happen simultaneously and the layout flashes during the crossover.

This is in the docs. It’s still the most common framer-motion mistake in the wild. The fix is two lines. Everyone gets bitten by it once.

The PrivacyChallenge accuracy update

The most important part of this commit isn’t the responsive plumbing. It’s the data:

PrivacyChallenge: accuracy updates with 2024-2025 breach data and citations

The PrivacyChallenge is a four-level interactive component where the user plays “data broker” trying to re-identify anonymized records. Each level uses a real-world re-identification attack (k-anonymity failure, demographic triangulation, ZIP+DOB+sex matching, free-text leakage), and each level cites a real published breach.

Before this commit, the citations were dated 2017–2020 — peer-reviewed but stale. After this commit, the citations include:

Every breach in the citation list is real, dated within 24 months of the demo, and verifiable via public reporting.

Why does this matter? Because the audience for this demo is healthcare buyers — IT directors, compliance officers, hospital CTOs — and they all know about the 2024 Change Healthcare breach. It cost UnitedHealth ~$22B in damages and direct response costs. Every healthcare buyer’s threat model has been re-shaped by it. A privacy demo that doesn’t reference the breach the audience just lived through is a demo that hasn’t done its homework.

The same is true of the other items. A 2017 breach is academic; a 2024 breach is “this could happen to my hospital next quarter.” The credibility of the demo is the credibility of its references.

Trust Score formula fix and Level 4 RNG removal

Two smaller fixes in the same commit, both addressing demo failure modes:

Trust Score formula. The demo computes a “Trust Score” (0–100) showing how identifiable a record is after the user’s deanonymization attempts. The original formula had an integer-division bug that produced 0 for any score below 1.0. The fix was switching to floating-point math. Tiny diff, big visible difference — instead of every level showing “Trust Score: 0,” the levels now show “Trust Score: 12 / 47 / 73 / 89” depending on how successful the user’s attack was.

Level 4 always awards 3 stars. The original Level 4 had an RNG-based reward — sometimes you got 3 stars for completing it, sometimes 2 stars, dependent on a Math.random() check. This was the wrong design. A demo cannot have non-deterministic UX, because if the demo person hits “the bad random roll” in front of a buyer, the buyer thinks the product is buggy. Removing the RNG and always awarding 3 stars on completion is the right call. The interactive challenge isn’t a casino; it’s a learning experience.

The lesson: deterministic demos beat dynamic demos every time. If you want randomization, save it for the production app.

What I’d do differently

The mobile drawer should have a swipe-to-close. Right now you tap the backdrop or the close button. A swipe-left would be more native. Framer Motion’s drag API would do it in 10 lines.

The HUD’s top-14 is hardcoded. A CSS custom property --mobile-header-height: 3.5rem set on the body would let the HUD position itself relative to the real header height, not a magic number that goes wrong if the header ever changes.

The useMediaQuery hook should default to a server-safe value. As written, the hook returns false on SSR, which would cause a flash if this demo ever ran with hydration. The Zera Med demo is pure CSR so it doesn’t hit this, but the hook is a re-usable building block I should harden.

Trade-offs

Why not use a router-aware drawer library? Because the demo only has one drawer, on one page. Adding vaul or @radix-ui/react-dialog for one drawer is overkill. Framer Motion’s motion.aside with hand-rolled state is 60 lines of code and zero new dependencies.

Why responsive at the design-token level (Tailwind classes) instead of CSS-in-JS? Because Tailwind’s responsive utilities are inline-readable. lg:top-0 reads like “on lg+, top is 0,” which is faster to skim than a styled-components prop spread across multiple breakpoints. The cost is verbosity; the benefit is grep-ability.

Why update breach citations instead of removing them? Because the citations are the strongest argument the demo makes. Removing them would weaken the privacy case from “here’s why this matters, citing real recent breaches” to “trust me, privacy matters.” The harder pitch.

What this taught me

A demo that doesn’t survive a phone is a demo that loses one in three viewers, even when the phone-watcher is a passive observer. Responsive design isn’t optional even for desktop-target apps; it’s the cost of admission for any web-shipped product.

The accuracy/citation work taught me that demo data quality is the demo. The same modal animation, with stale 2017 breach data, is a less compelling product than the same modal with 2024 breach data. The cryptography is the same. The conviction in the audience is different.

Further reading

← Back to article