For this project I wanted to focus on component-level styling and easy configuration, all using React and Typescript. In part 1 we’ll plan and set up the repo and create the 2 main components. We’ll also leverage styled-components for custom styling.
Planning & Requirements Documentation
We’ll have our main component the timeline with the follow properties:
Timeline
- Title | string
- Introduction | string | optional
- Conclusion | string | optional
- Event List | Array of TimelineEventTypes
- Config | string
- Could be changed to an object eventually for more complex settings. For now, its just a string that corresponds to the style of timeline
We also want a child component that will handle the timeline events.
TimelineEvent
- Id | number ( using index )
- Config | inherited from parent
- Item Object | TimelineEventType object data
- Item styles | will use this value to customise space between events based on time between events.
Ideally, we would put the id on the TimelineEventType info so that the ids stayed with the specific events regardless of the order they are rendered in the app. However, for now we are going to keep it simple and just use the index of the item in the array.
Lastly, let’s be specific about our TimelineEventType data:
TimelineEventType
- Title | string
- Display_Date | string | fallback on Year.toString() ?
- Year | number
- Description | number | optional
- Era | string | optional label for the group this event belongs to. Influences color styling for this event’s section of the timeline.
This last bit represents the information we expect each event on the timeline to have when it initially arrives in the app. The Timeline event component will then get or derive the other information it needs from the parent and it’s calculations.
Setup
We’re ready to set up our repo and write some code. Let’s create our project folder and clone a good starter repo in to get us going. I’m using create-react-app with the typescript template.
In the terminal, run:
Npx create-react-app react-ts-timeline –template typescript
From here I open my new repo in VScode and delete the files I won’t be using this time: extra jest test files, default logo, index.css file (we will focus on component specific styles).
Speaking of those, we want to install styled-components and their types
Npm i styled-components @types/styled-components
Now is a good time to make your first commits and push your repo out to some sort of version control. For styled-components, you’ll likely want css highlighting within your JS files too. There’s a great plugin for that in VScode called vscode-styled-components.
Add & Import Data into App.tsx
Before building anything, it’d be nice to know what our source of data is going to look like. To start, we are going to provide our timeline data via a JSON file.
I’ve created a folder inside src called ‘data’. Inside of that, we have data.json.
My data.json file has a timeline of events related to scientific thinking and discovery. Your .json file can have whatever specific information you want.
Note: I am reusing this data from another project, so there are extra fields. The only fields you need for your timelineEvents are the ones we outlined in the planning stage.
{
"title": "The Rise of Systems Thinking",
"introduction": "Using Web of Life Ch. 2 - Capra (1996)",
"config": "slider",
"timelineEvents": [
{
"Title": "Aristotles theories on matter",
"Year": -335,
"End Year": -323,
"Display_Date": "~330 B.C.",
"Description": "The first biologist in Western tradition, Aristotle believed substance and form were linked. He created a formal system of logic and a set of unifying concepts for the main disciplines of his time-- biology, physics, metaphysics, ethics, and politics.",
"Media": "http://www.abc.net.au/radionational/image/3877530-3x2-940x627.jpg",
"Media Credit": null,
"Media Caption": null,
"Media Thumbnail": null,
"Type": null,
"Group": "Early Science",
"Era": "Cartesian-Mechanics",
"Background": "#d0e0b8"
},
{
"Title": "Galileos discoveries",
"Year": 1564,
"End Year": 1642,
"Display_Date": "1620s",
"Description": "Galileo studies phenomena that can be measured and quantified. The view of the world as a machine begins to emerge",
"Media": null,
"Media Credit": null,
"Media Caption": null,
"Media Thumbnail": null,
"Type": null,
"Group": "Early Science",
"Era": "Cartesian-Mechanics",
"Background": "#d0e0b8"
},
{ … etc, as many events as you want,…},
{
"Title": "Systems Thinking",
"Year": 1950,
"End Year": 2000,
"Display_Date": "2nd half of the 19th century",
"Description": "According to the systems view, the essential properties of an organism, or living system, are properties of the whole - which none of the parts have. They arise from the interactions and relationships among the parts. This was a great shock to twentieth century science that systems cannot be understood by reductionist analysis. The properties of the parts are not intrinsic properties but can be understood only within the context of the larger whole. \nThus, the relationships between parts and whole has been reversed within Western Science. In the systems approach the properties of the parts can only be understood from the organization of the whole.",
"Media": null,
"Media Credit": null,
"Media Caption": null,
"Media Thumbnail": null,
"Type": null,
"Group": "Modern Science",
"Era": "Systems-Thinking",
"Background": null
}
]
}
Once we have our .json file, we need to import it into our App.tsx
import { default as dataJSON } from "./data/data.json";
We can also assign our data to variables for later usage. I’m going to put my data object in a useState variable so that React can track when the information changes and update our timeline display accordingly.
Here’s how our App.tsx file is looking at this point:
function App() {
const [timelineData, setTimelineData] = useState(dataJSON);
const timelineTitle = timelineData.title;
const timelineEventData = timelineData.timelineEvents;
const timelineIntro = timelineData.introduction;
const timelineConfig = timelineData.config;
return (
<div className="App">
<Timeline
title={timelineTitle}
config={timelineConfig}
timelineEvents={timelineEventData}
/>
</div>
);
}
export default App;
We’ve already passed some props to timeline but we don’t have the component written yet. Let’s go build that out.
Create Timeline.tsx file
Go to your src folder and create 2 files:
- Timeline.tsx file – main component file
- Timeline.styles.ts file – computed styles for the component
Now let’s define our Timeline.tsx functional component according to the planning we did earlier. We know the properties we want to accept, as well as the types we want typescript to check for – so fill out that information first.
For the timelineEvents list, we want to check for a list of objects with specific properties. Let’s define those list objects in more detail for typescript. We are calling them TimelineEventType objects:
export type TimelineEventType = {
Title: string;
Display_Date: string;
Year: number;
Description: string | null;
Era?: string | null;
};
In my project I defined this type in the App.tsx file and imported it into the components that need it. For better portability, it might make sense to define this type inside of Timeline.tsx. Regardless of where you define it, you will need to export it so that both the Timeline and TimelineEvent files can access the definition.
Speaking of accessing it, here’s how you import it into Timeline.tsx from App.tsx to use:
import { TimelineEventType } from "../App";
Now we can use this definition for our list of timelineEvents. We indicate it’s a list of objects and not a single object by including the [] after.
timelineEvents: TimelineEventType[];
In the body of the component, we will render a list of TimelineEvent components using map(). We’ll add more to this later.
import React, { ReactNode, useState, useEffect } from "react";
//styles element
import { Wrapper } from "./Timeline.styles";
//components
import TimelineEvent from "../TimelineEvent/TimelineEvent";
//types
import { TimelineEventType } from "../App";
function Timeline({
title,
introduction,
conclusion,
config,
timelineEvents,
}: {
title: string;
introduction?: string;
conclusion?: string;
config?: string;
timelineEvents: TimelineEventType[];
}) {
//RENDER timeline
//return a list of timeLineEvents
return (
<Wrapper className={config}>
<div className="timeline-header">
<h1>{title}</h1>
</div>
<ul style={timelineStyles}>
{timelineEvents?.map((item: TimelineEventType, index) => {
//render event
return (
<TimelineEvent
key={index}
id={index.toString()}
item={item}
config={config}
style={spacerStyles}
/>
);
})}
</ul>
</Wrapper>
);
}
export default Timeline;
Create Timeline.styles.ts file
We also need to create our styled components file so we can inject CSS into our component <Wrapper>. We’ll add a lot more to this later. For now it’s essentially a blank styles file.
import styled from "styled-components";
export const Wrapper = styled.div``
Be sure to go back to App.tsx and import the finished timeline component so that it can render.
import Timeline from "./Timeline/Timeline";
Create folder and files for TimelineEvent
Repeat the steps you took for the Timeline component to create the TimelineEvent component.
- Create a folder inside src called ‘TimelineEvent’
- Create 2 files: TimelineEvent.tsx and TimelineEvent.styles.ts.
Inside TimelineEvent.tsx, we need to once again define the properties we expect and their types. To do this, we need to import our custom TimelineEventType from our app file.
import { TimelineEventType } from "../App";
Because we are receiving a property that corresponds to CSS styles, we also need to import the typing for that:
import CSS from "csstype";
We’re also going to import our styles and a material-ui component to help with the display of our events.
In the return statement of the component, we’ll render the basic information about our event inside of the material-ui ‘Card’ component we imported.
We’ll also pass the style info we received from the Timeline to our event so that we can calculate the space between events and style accordingly in the next step.
Lastly, we’re going to check if our event has an ‘Era’ property. If it does, we’re going to write the era into the element’s class name. Again, this is for easy styling later.
Here’s how our TimelineEvents.tsx looks for now:
import { ReactNode } from "react";
//types
import { TimelineEventType } from "../App";
import CSS from "csstype";
//styles
import Card from "@material-ui/core/Card";
import { WrapperListItem } from "./TimelineEvent.styles";
function TimelineEvent({
item,
style,
config,
id,
}: {
item: TimelineEventType;
style: CSS.Properties;
config?: string;
id: string;
}) {
return (
<WrapperListItem
id={id}
style={style}
className={config + " " + (item.Era ? item.Era : "")}
>
<div className="wrapper">
<Card>
<h1>
{item.Title} - {item.Year}
</h1>
<h3>{item.Display_Date}</h3>
<p>{item.Description}</p>
</Card>
</div>
</WrapperListItem>
);
}
export default TimelineEvent;
TimelineEvent.styles.ts is going to be basically the same as the styles file for Timeline right now. The only difference is that we are styling a li element called WrapperListItem instead of a div called Wrapper.
import styled from "styled-components";
export const WrapperListItem = styled.li``
Derive styles based on time between events
Our timeline renders a list of events but it would be nice if they were spaced according to how much time had passed between them, like a real timeline.
Let’s experiment with what we can achieve by increasing the padding on the event according to how much time has passed between it and the previous event.
Because we need to be aware of the previous event, we’ll do this in the parent component: Timeline.tsx . Let’s setup a variable called prevEventYear to track the information we need: the year the previous event occurred.
//vars
let prevEventYear = 0; //tracks space between timelineEvents
Now we can edit our loop through timelineEvents later in the file.
We’ll grab the year of our current event minus the value of the year our previous event took place. Then we’ll use Math.abs() to get the absolute value of that operation just in case we are dealing with B.C. dates. We’ll assign this to a variable called spaceBetween.
spaceBetween = Math.abs(item.Year - prevEventYear);
Then we’ll write the value of spaceBetween into our styles for that event. Lastly, we’ll pass this style information to our TimelineEvent through the style property we already set up.
<ul style={timelineStyles}>
{timelineEvents?.map((item: TimelineEventType, index) => {
//calc spaceBetween
let spaceBetween = 0;
if (prevEventYear !== 0) {
spaceBetween = Math.abs(item.Year - prevEventYear);
}
//create styles
let spacerStyles = null;
//for 'slider' config
if (config === "slider") {
spacerStyles = {
paddingLeft: spaceBetween + "px",
};
} else {
//use default
spacerStyles = {
paddingTop: spaceBetween + "px",
};
}
//set for next
prevEventYear = item.Year;
//render
return (
<TimelineEvent
key={index}
id={index.toString()}
item={item}
config={config}
style={spacerStyles}
/>
);
})}
</ul>
Style our components with our .styles.ts files
You may have noticed our code snippet above checks for the value of config and then sets either left or top padding accordingly. That’s because we are using our config value to toggle between different styles of timeline.
Technically you could have as many layouts as you’d like, but for this sample we are going to create 2: a default layout that is a vertical list of events, and a slider layout that is more like a horizontal list of events, with some extra navigation features.
For now the only part of the style we need to calculate is the spaceBetween value. However, we have a lot of other css rules we want to apply to our component based on the value of config.
Let’s fill in those style files now. We’ll also create some variables for values we want to reuse.
import styled from "styled-components";
//vars
const headerHeight = '20vh'
//colors
const timelineBg = '#333'
const timelineTextColor = '#fff'
export const Wrapper = styled.div`
position: relative;
margin:0;
background: ${timelineBg};
color: ${timelineTextColor};
.timeline-header {
h1 {
margin-top:0;
padding-top:20px;
}
}
.nav-buttons {
display: none;
}
ul {
padding: 0px;
}
&:not(.slider){
@media only screen and (max-width: 759px) {
ul::before {
content:'';
position: absolute;
border: 1px solid;
height: 100%;
right: 50%;
}
}
}
&.slider {
overflow-y: hidden;
overscroll-behavior-x: contain; //prevent browser 'back' when overscrolling to start
.timeline-header {
text-align: center;
height: ${headerHeight};
width: 100vw;
position: fixed;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding-bottom:10px;
border-bottom: 1px solid ${timelineTextColor};
.nav-buttons {
display: flex;
flex-direction: row;
justify-content: center;
.MuiButton-root {
color: ${timelineTextColor};
border-color: ${timelineTextColor};
margin:0 10px;
}
}
}
ul {
padding-top: ${headerHeight};
height: calc(95vh - ${headerHeight});
background: ${timelineBg};
}
}
`
And TimelineEvents.styles.ts looks like:
import styled from "styled-components";
//vars
const headerHeight = '20vh'
//colors
const timelineBg = '#333'
const timelineTextColor = '#fff'
export const Wrapper = styled.div`
position: relative;
margin:0;
background: ${timelineBg};
color: ${timelineTextColor};
.timeline-header {
h1 {
margin-top:0;
padding-top:20px;
}
}
.nav-buttons {
display: none;
}
ul {
padding: 0px;
}
&:not(.slider){
@media only screen and (max-width: 759px) {
ul::before {
content:'';
position: absolute;
border: 1px solid;
height: 100%;
right: 50%;
}
}
}
&.slider {
overflow-y: hidden;
overscroll-behavior-x: contain; //prevent browser 'back' when overscrolling to start
.timeline-header {
text-align: center;
height: ${headerHeight};
width: 100vw;
position: fixed;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding-bottom:10px;
border-bottom: 1px solid ${timelineTextColor};
.nav-buttons {
display: flex;
flex-direction: row;
justify-content: center;
.MuiButton-root {
color: ${timelineTextColor};
border-color: ${timelineTextColor};
margin:0 10px;
}
}
}
ul {
padding-top: ${headerHeight};
height: calc(95vh - ${headerHeight});
background: ${timelineBg};
}
}
`
And TimelineEvents.styles.ts looks like:
import styled from "styled-components";
//vars
const buttonSize = '15px'
//colors - timeline eras
const defaultTimelineColor = 'black';
//era theme - honeybee
const era1 = '#f2e640';
const era2 = '#f2cb05';
const era3 = '#f2b707';
const era4 = '#f29f07';
const era5 = '#d96706';
export const WrapperListItem = styled.li`
list-style: none;
&.Cartesian-Mechanics {
border-color: ${era1} !important;
}
&.The-Romantic-Movement {
border-color: ${era2} !important;
}
&.Nineteenth-Century-Mechanism {
border-color: ${era3} !important;
}
&.Vitalism {
border-color: ${era4} !important;
}
&.Organismic-Movement {
border-color: ${era5} !important;
}
&.Systems-Thinking {
border-color: ${era1} !important;
}
&:not(.slider){
.wrapper{
position:relative;
::after {
content:'';
height:${buttonSize};
width:${buttonSize};
background-color:#fff;
border-radius: calc(${buttonSize}/2);
border: 1px solid ${defaultTimelineColor};
position: absolute;
bottom: calc(-${buttonSize}/2);
right: calc(50% - ${buttonSize}/2);
}
}
@media only screen and (min-width: 760px) {
.wrapper::after {
bottom: 50%;
}
&:nth-child(even){
margin-left:50%;
border-left:1px solid ${defaultTimelineColor};
padding-left:1%;
.wrapper::after {
right: calc(102% - ${buttonSize}/2);
}
}
&:nth-child(odd){
width:49%;
padding-right:1%;
border-right:1px solid ${defaultTimelineColor};
.wrapper::after {
content:'';
left: calc(102% - ${buttonSize}/2);
}
}
}
}
&.slider {
color: blue;
float: left;
width: 50vw;
height: 60vh;
border-bottom: 1px solid ${defaultTimelineColor};
.MuiCard-root {
padding: 20px;
}
}
`
Note: You can see I have hard-coded the values of my Era groups for now to assign colors to them. We could make this more dynamic by providing these colors and class names through the config. I will leave that up to you.
These are just some basic starter rules to create a vertical and horizontal layout. Tweak as much as you like.
Conclusion & Demo Link
You can find the repo for this project at on github.
That’s it for part 1! Look out for improvements to this project coming soon in part 2!