Building a Nutritional Tracker: Part 6 — Visual Architecture & Accessibility

3 min

Building a Nutritional Tracker: Part 6 — Visual Architecture & Accessibility

Introduction

After completing a form with validation and persistence, the inevitable question arose: how does this look in the user’s hands?

The honest answer: not great. Raw HTML produces frustrating interfaces—small touch targets, low contrast, and unclear navigation. In this chapter, I document how I took the UI from “barebones” to a consistent, mobile-first, accessible, and maintainable visual layer.

Here you’ll find key decisions, implementation snippets, and tests that gave me confidence to deliver a usable experience on mobile and desktop.

(Place here screenshot of the form BEFORE styling — raw, misaligned HTML)

(Place here screenshot of the form AFTER styling — mobile, clean, and modern)


My Design Guidelines

Before touching a Tailwind class, I defined guiding principles that informed every decision. These are summarized from my style-guidelines.md file:

Core Principles

  • Mobile-first by default (high % of mobile usage).
  • Touch targets ≥ 48 px (better usability and accessibility).
  • Minimum WCAG AA contrast.
  • Labels always visible—never use placeholder as the only label.
  • Immediate feedback: inline validation and clear messages.

Input-Specific Decisions

  • Short forms: ideally 3–5 fields per screen.
  • Avoid selects for very short lists → prefer radios.
  • Use native types: type="number", type="date", etc.
  • On mobile: single-column layout for easier scrolling and focus.

Why TailwindCSS v4

The choice was pragmatic: rapid prototyping and consistency without the overhead of custom CSS.

Quick comparison:

  • CSS Modules: total control, but lots of boilerplate.
  • Styled Components: React integration, but adds weight.
  • Vanilla CSS: zero dependencies, but slow to iterate.
  • Tailwind v4: ultra-fast iteration, small bundle, mobile-first mindset.

Essential Configuration (tailwind.config.cjs)

// tailwind.config.cjs (excerpt)
module.exports = {
  theme: {
    extend: {
      colors: {
        primary: { DEFAULT: '#0ea5e9', light: '#38bdf8', dark: '#0369a1' },
        secondary: { DEFAULT: '#f59e42', light: '#fbbf24', dark: '#b45309' },
        error: { DEFAULT: '#ef4444', bg: '#fee2e2' },
        success: { DEFAULT: '#22c55e', bg: '#dcfce7' },
      },
      fontFamily: {
        sans: ['Inter', 'system-ui', 'sans-serif'],
        heading: ['Montserrat', 'Inter', 'sans-serif'],
      },
    },
  },
}

Reusable UI Components

The most repeated problem: duplicating the same label + input + error pattern seven times. The solution was to create single-responsibility components.

Before: duplication and risk of inconsistencies

<label className="block text-sm font-medium text-gray-700">
  Food <span className="text-red-600">*</span>
</label>
<input className="mt-1 w-full px-4 py-2 border border-gray-300 rounded-md" />
<span className="text-sm text-red-600">Required field</span>

After: components with a clear API

  • Label.tsx — shows the label, accessible asterisk, and sr-only for screen readers:
<label className="block text-sm text-gray-700 font-medium">
  {children}
  {required && (
    <>
      <span aria-hidden="true" className="text-red-600"> *</span>
      <span className="sr-only"> required</span>
    </>
  )}
</label>
  • Input.tsx — with forwardRef, state handling (default, error, focus), and shared classes.

  • RadioGroup — replaced selects in many cases; better for mobile due to large touch targets:

<RadioGroup
  name="mealType"
  options={mealTypeOptions.map(type => ({
    value: type,
    label: MEAL_TYPE_LABELS[type],
  }))}
  layout="grid"
/>

Accessibility: Applied Theory and Automated Testing

It’s not enough to think “it’s accessible”; you have to measure it. I integrated axe-core into unit tests to validate accessibility in dynamic states.

Example test (Jest + Testing Library + axe):

it('form with visible errors remains accessible', async () => {
  // ... simulate empty submit and show errors
  const results = await runAxe(container)
  expect(results.violations).toHaveLength(0)
})

Actual CI result: 0 violations even with visible errors.

I also tested keyboard navigation and visible focus in DevTools.


Storybook: Visual QA and Living Documentation

Storybook let me explore concrete states and automate interactions:

  • addons: a11y and interactions.
  • useful stories I keep maintained:
    • Button: Primary / Secondary / Loading / Disabled
    • Input: Default / Error / Typing (with play function)
    • RadioGroup: Vertical / Grid / Error
    • RegistrationForm: MinimalSubmit (happy path automated)

Current Project Status

What works today:

  • ✅ 100% mobile-first form with proper touch targets
  • ✅ Contrast automatically validated (axe-core = 0 violations)
  • ✅ Components documented and tested in Storybook

Next steps:

  • Data visualization (charts and reports)
  • Search and filtering in history

Final Thoughts

Next part → Part 7: Data visualization and reports.


Series Navigation

Additional Resources