Building a Workout Diary Using Framer Motion

July 17, 2024

  • Insights
Thumbnail

Rauno’s Running Diary is an interactive scroll-based web UI designed to look like a sliding ruler. On mobile, the UI is scroll-based, while on the desktop, a custom cursor reacts to its position, morphing into either a circle or a line. When the cursor hovers over a "Hatch Mark," information appears on the UI.

A "Hatch Mark" refers to each marking line on a ruler. In this UI, each hatch mark represents a day in a year. The length of each hatch mark varies depending on the number of workouts performed on that day.

The subtle animations and interactions created by Rauno are incredible and have a premium feel. When the line cursor moves along the hatch mark, a tick sound plays, resembling the sound of the classic iPod's scroll wheel being turned. You can check out his demo here.

I was intrigued by how this UI was made. So, I delved into the Voids, reverse-engineered Rauno’s work, and began to rebuild it myself.

Since I usually do not run, preferring other forms of cardio and weight training, I call mine the "Workout Diary." Here is the final result!

Getting a Data Source

I usually record my workouts using my Apple Watch, so I already had the data for the UI ready for the project. The challenge was how to sync the data from my iPhone’s Health app in real-time with the UI. Fortunately, Rauno provided a hint: Strava.

Strava Showcase

Strava is essentially a fitness tracker combined with a social media platform for athletes. It allows users to connect, upload, and sync workout data directly from the Health app and other apps into their cloud. Strava also offers APIs for developers to fetch data for other uses.

Working with the Strava API is straightforward. First, you register your app on their platform to retrieve API keys and secrets for making API requests. You also need to authenticate and authorize before accessing the data from the API, which can be done through their OAuth2 support.

Once authorized, you can begin fetching data from Strava. You can read more about Strava authentication here.

Architecting the Project

In developing this project within the Voids ecosystem, leveraging Next.js for both server-side rendering (SSR) and client-side rendering (CSR) dynamically offers significant advantages. We'll structure the project into two distinct parts: data fetching and processing, and the graphical user interface rendering.

Why Divide the Project?

By separating data processing and rendering responsibilities:

  • Data Fetching and Processing: Utilize server-side capabilities of Next.js to benefit from data caching, secure fetching, and efficient server-side computations.
  • Graphical User Interface Rendering: Offload rendering computations to the client-side, optimizing for interactivity and dynamic updates without compromising initial page load.

Data Processing on the Server-Side

Employing Next.js Server Components, we harness:

  • Data Fetching: Secure, cached data retrieval to enhance performance and reduce external API calls.
  • Security and Caching: Leveraging Vercel's Serverless SQL with PostgreSQL for efficient data storage and retrieval, ensuring robustness against Strava API rate limits.

Example: Token Management and Caching

Here's a streamlined function example for managing API tokens and caching them using PostgreSQL:

import dayjs from 'dayjs'
import axios from 'axios'
import Secret from '@/models/Secret' // Example model for database interaction

const getToken = async (code) => {
	try {
		// Fetch token from database
		const secret = await Secret.findOne({ where: { key: 'strava_token' } })
		if (secret && secret.value) {
			const token = JSON.parse(secret.value)

			// Check token expiry
			if (dayjs(token.expires_at).isAfter(dayjs())) {
				return token.access_token
			} else {
				// Refresh token if expired
				const refreshedToken = await refreshToken(token.refresh_token)
				if (refreshedToken) {
					await Secret.update(
						{ value: JSON.stringify(refreshedToken) },
						{ where: { key: 'strava_token' } }
					)
					return refreshedToken.access_token
				}
			}
		}

		// Return null if no code is provided
		if (!code) {
			return null
		}

		// Fetch new token from Strava
		const res = await axios.post('https://www.strava.com/api/v3/oauth/token', {
			client_id: '[CLIENT_ID]',
			client_secret: '[CLIENT_SECRET]',
			grant_type: 'authorization_code',
			code,
		})

		// Store token in database
		await Secret.destroy({ where: { key: 'strava_token' } })
		await Secret.create({
			key: 'strava_token',
			value: JSON.stringify(res.data),
		})

		return res.data.access_token
	} catch (error) {
		console.error('Error fetching token:', error)
		return null
	}
}

This approach ensures efficient token management and secure data fetching, optimizing API usage and enhancing application reliability.

Rendering the UI

Once data processing is complete, the data is seamlessly passed as props to the WorkoutDiary component for rendering:

// Example data processing code
const Page = async () => {
	const items = await getAllActivities()

	return (
		<WorkoutDiary
			items={JSON.parse(JSON.stringify(items))}
			viewingYear={viewingYear}
		/>
	)
}

export default Page

This setup maximizes performance, security, and maintainability by leveraging Next.js capabilities for server-side processing and client-side rendering, ensuring a smooth and responsive user experience.

There are 2 main parts to the UI: the cursor and the timeline. The main animation library that we will be using is Framer Motion.


Introducing Framer Motion

Framer Motion is a powerful library for adding fluid animations and interactive elements to React applications. It simplifies the choreography of complex animations and gestures, making it easier to create delightful user experiences without compromising performance.

Key Features of Framer Motion:

  • Declarative API: Framer Motion embraces a straightforward API that allows developers to define animations and interactions declaratively, using components like motion.div and motion.button.
  • Animation Variants: Define variants for components to seamlessly transition between different states. This can include animations for hover effects, page transitions, or complex multi-step animations.
  • Gesture Recognition: Easily incorporate gestures like drag, pinch, and swipe into your UI components, enhancing interactivity and user engagement.
  • Spring Physics: Framer Motion’s physics-based animations provide natural-looking motion with customizable spring parameters, giving your UI a polished and responsive feel.

Example of Framer Motion in Action

Why Framer Motion?

Framer Motion stands out for its simplicity and flexibility in creating interactive and visually appealing animations. Whether you're building a portfolio site, a dashboard, or an e-commerce platform, Framer Motion empowers developers to bring designs to life with ease.

In the "Workout Diary" project, Framer Motion played a crucial role in animating the custom cursor, enhancing the user experience by providing smooth transitions and engaging interactions.


Rendering the Timeline

The Timeline component presents a horizontal timeline where each day is represented by vertical bars indicating activities. Here’s an overview:

  1. Component Structure:
    • Receives items prop containing daily data.
  2. Layout:
    • Wrapped in a <div> with timeline ID.
    • Each day’s data displayed as a <div> with a data-day attribute formatted by dayjs.
  3. Activity Representation:
    • Vertical bars inside each day signify activities.
    • Bar height adjusts based on activity duration.
  4. Visual Indicators:
    • Highlights the start of each month with a red bar.
    • Differentiates days with activities from those without.
import dayjs from 'dayjs'

const Timeline = ({ items, isMobile }) => {
	return (
		<div className="flex items-center justify-start w-full h-full">
			<div id="timeline">
				<div className="flex items-center justify-start gap-[6px] px-[50vw] md:px-[120px] min-h-[300px]">
					{items.map((item, i) => (
						<div
							key={i}
							className="relative gap-[10px] w-px h-[300px] overflow-visible"
							data-day={dayjs(item.day).format('YYYY-MM-DD')}
						>
							<div className="absolute bottom-0 left-0 pointer-events-none">
								{item.activities.map((activity, j) => (
									<div
										key={j}
										className={`relative z-20 mt-[5px] w-px pointer-events-none ${
											dayjs(item.day).get('date') === 1 &&
											j === item.activities.length - 1
												? 'bg-red-600'
												: 'bg-white'
										}`}
										style={{
											height: `${activity.duration / (isMobile ? 70 : 50)}px`,
										}}
									/>
								))}
								<div
									className={`absolute bottom-0 left-0 z-10 h-[30px] w-px pointer-events-none ${
										dayjs(item.day).get('date') === 1
											? 'bg-red-600'
											: item.activities.length === 0
											? 'bg-neutral-600'
											: 'bg-transparent'
									}`}
								/>
							</div>
						</div>
					))}
				</div>
			</div>
		</div>
	)
}

export default Timeline

Custom Cursor Implementation

To implement a custom cursor, we start by hiding the OS cursor using CSS. This ensures that our custom cursor remains visible across all elements on the webpage:

* {
	cursor: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNgYAAAAAMAASsJTYQAAAAASUVORK5CYII='),
		none !important;
}

Here, we use a Data URL with a Base64-encoded string representing a very small PNG image. This sets a tiny, invisible custom cursor and ensures it's applied universally with the !important directive.

Cursor States

Next, we create the HTML structure for the custom cursor in React using a CustomCursor component. This component uses Framer Motion for smooth animations between cursor states:

import { useState, useEffect } from 'react'
import { motion } from 'framer-motion'

// Define the main component
const CustomCursor = ({ isHoveringTimeline }) => {
	const [coords, setCoords] = useState({ x: 0, y: 0 })

	useEffect(() => {
		const handleMouseMove = (event) => {
			setCoords({
				x: event.clientX,
				y: event.clientY,
			})
		}

		document.addEventListener('mousemove', handleMouseMove)
		return () => {
			document.removeEventListener('mousemove', handleMouseMove)
		}
	}, [])

	return (
		<motion.div
			className="fixed z-50 pointer-events-none"
			style={{
				transform: `translate3d(${coords.x}px, ${coords.y}px, 0) translateZ(0)`,
				transition: 'transform 0.4s cubic-bezier(0.215, 0.61, 0.355, 1)',
			}}
		>
			{/* Inner cursor */}
			<motion.div
				className="absolute bg-red-600 rounded-full"
				animate={{
					opacity: isHoveringTimeline ? 1 : 0.6,
					width: isHoveringTimeline ? 2 : 44,
					height: isHoveringTimeline ? 400 : 44,
				}}
				transition={{
					duration: 0.3,
					ease: 'cubic-bezier(0.215, 0.61, 0.355, 1)',
				}}
			>
				{/* Content inside the cursor */}
			</motion.div>
		</motion.div>
	)
}

export default CustomCursor

Adding Interactivity to the Cursor

Now, let’s enhance the cursor by displaying relevant content based on user interaction. We can detect which day the cursor is hovering over and display that day's activities above the cursor. This requires integrating with the timeline's data attributes and updating the cursor's state dynamically.

Connecting Cursor Component with Timeline Data

To achieve dynamic updates based on user interaction:

  1. Integrate Data Attributes: Ensure each timeline element (representing days) has a corresponding data attribute that identifies the day.
  2. Update Cursor State: Connect the cursor component to listen for mouse movements and update its state (activeDay) based on the hovered day from the timeline.

Here’s how you can implement this integration:

import { useState, useEffect } from 'react'
import { motion } from 'framer-motion'

const CustomCursor = ({ isHoveringTimeline }) => {
	const [coords, setCoords] = useState({ x: 0, y: 0 })
	const [activeDay, setActiveDay] = useState('')

	useEffect(() => {
		const handleMouseMove = (event) => {
			setCoords({
				x: event.clientX,
				y: event.clientY,
			})
		}

		document.addEventListener('mousemove', handleMouseMove)
		return () => {
			document.removeEventListener('mousemove', handleMouseMove)
		}
	}, [])

	const handleTimelineHover = (event) => {
		const day = event.target.dataset.day || ''
		if (day && day !== '') {
			setActiveDay(day)
		}
	}

	return (
		<motion.div
			className="fixed z-50 pointer-events-none"
			style={{
				transform: `translate3d(${coords.x}px, ${coords.y}px, 0) translateZ(0)`,
				transition: 'transform 0.4s cubic-bezier(0.215, 0.61, 0.355, 1)',
			}}
		>
			{/* Inner cursor */}
			<motion.div
				className="absolute bg-red-600 rounded-full"
				animate={{
					opacity: isHoveringTimeline ? 1 : 0.6,
					width: isHoveringTimeline ? 2 : 44,
					height: isHoveringTimeline ? 400 : 44,
				}}
				transition={{
					duration: 0.3,
					ease: 'cubic-bezier(0.215, 0.61, 0.355, 1)',
				}}
			>
				{/* Content inside the cursor */}
				<motion.div
					className="absolute left-1/2 top-[-5px] transform -translate-x-1/2 text-neutral-200 flex justify-center items-center"
					animate={{
						opacity: activeDay ? 1 : 0,
						filter: activeDay ? 'blur(0)' : 'blur(8px)',
					}}
					transition={{
						duration: 0.3,
						ease: 'cubic-bezier(0.215, 0.61, 0.355, 1)',
					}}
				>
					{activeDay && (
						<div className="absolute bottom-0">
							{/* Display activities for activeDay */}
							<ActivitySummary activities={getActivitiesForDay(activeDay)} />
						</div>
					)}
				</motion.div>
			</motion.div>
		</motion.div>
	)
}

export default CustomCursor

Explanation:

  • Data Attribute Integration: Ensure each timeline element (<div>, for example) includes a data-day attribute containing the date or identifier for that specific day.
  • Event Handling: Use handleTimelineHover to detect when the cursor hovers over a timeline element. Update activeDay state accordingly.
  • Dynamic Content Display: Render ActivitySummary dynamically based on activeDay, providing users with immediate feedback on their cursor's position relative to the timeline.

This approach enhances user interaction by dynamically updating the cursor's displayed content based on where the user hovers, improving usability and engagement.

Wrap Up

Building the "Workout Diary" using Framer Motion and Next.js has been an enlightening journey into creating interactive and dynamic user interfaces. Inspired by Rauno’s innovative "Running Diary," this project aimed to adapt similar principles to track and visualize personal workout data.

Key Takeaways

  • Interactive UI Design: The "Workout Diary" features a scroll-based interface on mobile and a custom cursor on desktop, enhancing user engagement and providing a seamless browsing experience.
  • Data Integration: Leveraging data from the Apple Health app synced via Strava's API demonstrated the power of real-time data synchronization and visualization in web applications.
  • Technical Implementation: Using Next.js for server-side rendering and client-side interactivity allowed for efficient data fetching, processing, and dynamic UI updates, optimizing both performance and user experience.

Future Enhancements

  • Enhanced Interactivity: Further improving cursor animations and integrating additional hover effects could elevate user interaction, making the diary even more intuitive and engaging.
  • Data Insights: Exploring ways to visualize workout trends and statistics over time could provide valuable insights into fitness progress and habits.

Get Started

If you're interested in exploring more about building interactive web interfaces and integrating real-time data, consider experimenting with Framer Motion, Next.js, and APIs like Strava. The possibilities for creating personalized, data-driven experiences are limitless.

With these insights and tools at your disposal, you can embark on creating your own interactive projects that push the boundaries of web design and user interaction. Happy coding!