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 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!
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.
(Photo by Charlie Hammond on Unsplash)
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.
In developing this project within the Experiments 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.
By separating data processing and rendering responsibilities:
Employing Next.js Server Components, we harness:
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.
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.
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.
motion.div
and motion.button
.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.
The Timeline
component presents a horizontal timeline where each day is represented by vertical bars indicating activities. Here’s an overview:
items
prop containing daily data.<div>
with timeline
ID.<div>
with a data-day
attribute formatted by dayjs
.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;
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.
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;
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.
To achieve dynamic updates based on user interaction:
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;
<div>
, for example) includes a data-day
attribute containing the date or identifier for that specific day.handleTimelineHover
to detect when the cursor hovers over a timeline element. Update activeDay
state accordingly.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.
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.
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!