Display
Timeline
Timeline whose dot aligns itself to every title
Preview
01
Milestone 1
Milestone description.
02
Milestone 2
Milestone description.
03
Milestone 3
Milestone description.
size
layout
current
Code
import css from "./Timeline.module.css";
type Size = "sm" | "md";
type Layout = "stacked" | "axis";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface ResponsiveLayout {
base: Layout;
mobile?: Layout;
tablet?: Layout;
desktop?: Layout;
wide?: Layout;
}
interface TimelineProps {
size: Size | ResponsiveSize;
layout: Layout | ResponsiveLayout;
children: React.ReactNode;
}
function getResponsiveClasses(
value: Size | Layout | ResponsiveSize | ResponsiveLayout,
): string {
if (typeof value === "string") {
return css[value];
}
const classes = [css[`${value.base}_base`]];
if (value.mobile) classes.push(css[`${value.mobile}_mobile`]);
if (value.tablet) classes.push(css[`${value.tablet}_tablet`]);
if (value.desktop) classes.push(css[`${value.desktop}_desktop`]);
if (value.wide) classes.push(css[`${value.wide}_wide`]);
return classes.join(" ");
}
export default function Timeline({
size,
layout,
children,
}: TimelineProps) {
return (
<ol
className={`
${css.timeline}
${getResponsiveClasses(size)}
${getResponsiveClasses(layout)}
`}
role="list"
>
{children}
</ol>
);
}
.timeline {
padding: 0;
/* The list only declares; every item reads the custom properties it inherits,
so the two files never reach into each other's classes. */
&.sm {
--timeline-dot: 12px;
--timeline-halo: 6px;
--timeline-gap: var(--space-32);
--timeline-inset: var(--space-16);
}
&.md {
--timeline-dot: 16px;
--timeline-halo: 10px;
--timeline-gap: var(--space-48);
--timeline-inset: var(--space-24);
}
&.sm_base {
--timeline-dot: 12px;
--timeline-halo: 6px;
--timeline-gap: var(--space-32);
--timeline-inset: var(--space-16);
}
&.md_base {
--timeline-dot: 16px;
--timeline-halo: 10px;
--timeline-gap: var(--space-48);
--timeline-inset: var(--space-24);
}
@media (min-width: 480px) {
&.sm_mobile {
--timeline-dot: 12px;
--timeline-halo: 6px;
--timeline-gap: var(--space-32);
--timeline-inset: var(--space-16);
}
&.md_mobile {
--timeline-dot: 16px;
--timeline-halo: 10px;
--timeline-gap: var(--space-48);
--timeline-inset: var(--space-24);
}
}
@media (min-width: 768px) {
&.sm_tablet {
--timeline-dot: 12px;
--timeline-halo: 6px;
--timeline-gap: var(--space-32);
--timeline-inset: var(--space-16);
}
&.md_tablet {
--timeline-dot: 16px;
--timeline-halo: 10px;
--timeline-gap: var(--space-48);
--timeline-inset: var(--space-24);
}
}
@media (min-width: 1024px) {
&.sm_desktop {
--timeline-dot: 12px;
--timeline-halo: 6px;
--timeline-gap: var(--space-32);
--timeline-inset: var(--space-16);
}
&.md_desktop {
--timeline-dot: 16px;
--timeline-halo: 10px;
--timeline-gap: var(--space-48);
--timeline-inset: var(--space-24);
}
}
@media (min-width: 1440px) {
&.sm_wide {
--timeline-dot: 12px;
--timeline-halo: 6px;
--timeline-gap: var(--space-32);
--timeline-inset: var(--space-16);
}
&.md_wide {
--timeline-dot: 16px;
--timeline-halo: 10px;
--timeline-gap: var(--space-48);
--timeline-inset: var(--space-24);
}
}
&.stacked {
--timeline-columns: var(--timeline-dot) 1fr;
--timeline-areas: ". year" "dot title" "dot title" ". body" ". gap";
--timeline-column-gap: var(--timeline-inset);
--timeline-year-height: auto;
--timeline-year-justify: flex-start;
--timeline-year-gap: var(--space-8);
}
&.axis {
--timeline-columns: 1fr var(--timeline-dot) 1fr;
--timeline-areas: ". . ." "year dot title" "year dot title" ". . body" ". . gap";
--timeline-column-gap: var(--timeline-gap);
--timeline-year-height: 0;
--timeline-year-justify: flex-end;
--timeline-year-gap: 0;
}
&.stacked_base {
--timeline-columns: var(--timeline-dot) 1fr;
--timeline-areas: ". year" "dot title" "dot title" ". body" ". gap";
--timeline-column-gap: var(--timeline-inset);
--timeline-year-height: auto;
--timeline-year-justify: flex-start;
--timeline-year-gap: var(--space-8);
}
&.axis_base {
--timeline-columns: 1fr var(--timeline-dot) 1fr;
--timeline-areas: ". . ." "year dot title" "year dot title" ". . body" ". . gap";
--timeline-column-gap: var(--timeline-gap);
--timeline-year-height: 0;
--timeline-year-justify: flex-end;
--timeline-year-gap: 0;
}
@media (min-width: 480px) {
&.stacked_mobile {
--timeline-columns: var(--timeline-dot) 1fr;
--timeline-areas: ". year" "dot title" "dot title" ". body" ". gap";
--timeline-column-gap: var(--timeline-inset);
--timeline-year-height: auto;
--timeline-year-justify: flex-start;
--timeline-year-gap: var(--space-8);
}
&.axis_mobile {
--timeline-columns: 1fr var(--timeline-dot) 1fr;
--timeline-areas: ". . ." "year dot title" "year dot title" ". . body" ". . gap";
--timeline-column-gap: var(--timeline-gap);
--timeline-year-height: 0;
--timeline-year-justify: flex-end;
--timeline-year-gap: 0;
}
}
@media (min-width: 768px) {
&.stacked_tablet {
--timeline-columns: var(--timeline-dot) 1fr;
--timeline-areas: ". year" "dot title" "dot title" ". body" ". gap";
--timeline-column-gap: var(--timeline-inset);
--timeline-year-height: auto;
--timeline-year-justify: flex-start;
--timeline-year-gap: var(--space-8);
}
&.axis_tablet {
--timeline-columns: 1fr var(--timeline-dot) 1fr;
--timeline-areas: ". . ." "year dot title" "year dot title" ". . body" ". . gap";
--timeline-column-gap: var(--timeline-gap);
--timeline-year-height: 0;
--timeline-year-justify: flex-end;
--timeline-year-gap: 0;
}
}
@media (min-width: 1024px) {
&.stacked_desktop {
--timeline-columns: var(--timeline-dot) 1fr;
--timeline-areas: ". year" "dot title" "dot title" ". body" ". gap";
--timeline-column-gap: var(--timeline-inset);
--timeline-year-height: auto;
--timeline-year-justify: flex-start;
--timeline-year-gap: var(--space-8);
}
&.axis_desktop {
--timeline-columns: 1fr var(--timeline-dot) 1fr;
--timeline-areas: ". . ." "year dot title" "year dot title" ". . body" ". . gap";
--timeline-column-gap: var(--timeline-gap);
--timeline-year-height: 0;
--timeline-year-justify: flex-end;
--timeline-year-gap: 0;
}
}
@media (min-width: 1440px) {
&.stacked_wide {
--timeline-columns: var(--timeline-dot) 1fr;
--timeline-areas: ". year" "dot title" "dot title" ". body" ". gap";
--timeline-column-gap: var(--timeline-inset);
--timeline-year-height: auto;
--timeline-year-justify: flex-start;
--timeline-year-gap: var(--space-8);
}
&.axis_wide {
--timeline-columns: 1fr var(--timeline-dot) 1fr;
--timeline-areas: ". . ." "year dot title" "year dot title" ". . body" ". . gap";
--timeline-column-gap: var(--timeline-gap);
--timeline-year-height: 0;
--timeline-year-justify: flex-end;
--timeline-year-gap: 0;
}
}
}
import css from "./TimelineItem.module.css";
interface TimelineItemProps {
year: React.ReactNode;
title: React.ReactNode;
current?: boolean;
children: React.ReactNode;
}
export default function TimelineItem({
year,
title,
current,
children,
}: TimelineItemProps) {
return (
<li
className={`
${css.item}
${current ? css.current : ""}
`}
aria-current={current ? "true" : undefined}
>
<div className={css.item_year}>{year}</div>
<span className={css.item_dot} aria-hidden="true"></span>
<div className={css.item_title}>{title}</div>
<div className={css.item_body}>{children}</div>
</li>
);
}
.item {
display: grid;
grid-template-columns: var(--timeline-columns);
grid-template-areas: var(--timeline-areas);
/* The title spans two twin rows: the line between them is its centre, and the
dot and the rail are placed on that line whatever the title measures. */
grid-template-rows: auto auto auto auto var(--timeline-gap);
column-gap: var(--timeline-column-gap);
&:last-child {
grid-template-rows: auto auto auto auto 0;
}
/* Two strokes per item, so the rail runs from dot to dot. The colour
is a background layer whose size animates: a scroll timeline would
freeze an animated colour on a theme switch. */
&::before,
&::after {
content: "";
grid-column: dot;
justify-self: center;
width: 2px;
background-color: var(--color-border);
background-repeat: no-repeat;
}
&::before {
grid-row: 1 / 3;
background-image: linear-gradient(var(--color-accent-secondary), var(--color-accent-secondary));
}
&::after {
grid-row: 3 / -1;
background-image: linear-gradient(var(--color-accent), var(--color-accent-secondary));
}
&:first-child::before,
&:last-child::after {
visibility: hidden;
}
@media (prefers-reduced-motion: no-preference) {
&::before,
&::after {
animation: timeline-draw linear both;
}
&:not(.current) .item_dot::after {
animation: timeline-reach linear both;
}
@supports (animation-timeline: view()) {
&::before,
&::after {
animation-timeline: view(block 0 50%);
animation-range: entry 0% entry 100%;
}
&:not(.current) .item_dot::after {
animation-timeline: view(block 0 50%);
animation-range: entry 0% entry 10%;
}
}
}
/* Axis: a zero-height line at the title's centre, so the year centres on it
without sizing the title rows. */
.item_year {
grid-area: year;
align-self: center;
display: flex;
align-items: center;
justify-content: var(--timeline-year-justify);
height: var(--timeline-year-height);
padding-bottom: var(--timeline-year-gap);
}
.item_dot {
position: relative;
z-index: var(--z-raised);
grid-area: dot;
align-self: center;
justify-self: center;
width: var(--timeline-dot);
height: var(--timeline-dot);
border: 2px solid var(--color-text-tertiary);
border-radius: var(--radius-full);
background: var(--color-bg);
/* The reached state: a layer in live colours whose opacity is what moves. */
&::after {
content: "";
position: absolute;
inset: -2px;
border-radius: inherit;
background: var(--color-accent);
opacity: 0;
}
}
.item_title {
grid-area: title;
align-self: center;
}
.item_body {
grid-area: body;
padding-top: var(--space-8);
}
&.current .item_dot {
box-shadow: 0 0 0 var(--timeline-halo) color-mix(in srgb, var(--color-accent) 40%, transparent);
&::after {
opacity: 1;
}
}
}
@keyframes timeline-draw {
from {
background-size: 100% 0;
}
to {
background-size: 100% 100%;
}
}
@keyframes timeline-reach {
to {
opacity: 1;
}
}
<script setup lang="ts">
import { computed } from "vue";
type Size = "sm" | "md";
type Layout = "stacked" | "axis";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface ResponsiveLayout {
base: Layout;
mobile?: Layout;
tablet?: Layout;
desktop?: Layout;
wide?: Layout;
}
interface TimelineProps {
size: Size | ResponsiveSize;
layout: Layout | ResponsiveLayout;
}
const { size, layout } = defineProps<TimelineProps>();
function responsiveClasses(
value: Size | Layout | ResponsiveSize | ResponsiveLayout,
): string {
if (typeof value === "string") {
return value;
}
const classes = [`${value.base}_base`];
if (value.mobile) classes.push(`${value.mobile}_mobile`);
if (value.tablet) classes.push(`${value.tablet}_tablet`);
if (value.desktop) classes.push(`${value.desktop}_desktop`);
if (value.wide) classes.push(`${value.wide}_wide`);
return classes.join(" ");
}
const sizeClasses = computed(() => responsiveClasses(size));
const layoutClasses = computed(() => responsiveClasses(layout));
</script>
<template>
<ol class="timeline" :class="[sizeClasses, layoutClasses]" role="list">
<slot></slot>
</ol>
</template>
<style scoped>
.timeline {
padding: 0;
/* The list only declares; every item reads the custom properties it inherits,
so the two files never reach into each other's classes. */
&.sm {
--timeline-dot: 12px;
--timeline-halo: 6px;
--timeline-gap: var(--space-32);
--timeline-inset: var(--space-16);
}
&.md {
--timeline-dot: 16px;
--timeline-halo: 10px;
--timeline-gap: var(--space-48);
--timeline-inset: var(--space-24);
}
&.sm_base {
--timeline-dot: 12px;
--timeline-halo: 6px;
--timeline-gap: var(--space-32);
--timeline-inset: var(--space-16);
}
&.md_base {
--timeline-dot: 16px;
--timeline-halo: 10px;
--timeline-gap: var(--space-48);
--timeline-inset: var(--space-24);
}
@media (min-width: 480px) {
&.sm_mobile {
--timeline-dot: 12px;
--timeline-halo: 6px;
--timeline-gap: var(--space-32);
--timeline-inset: var(--space-16);
}
&.md_mobile {
--timeline-dot: 16px;
--timeline-halo: 10px;
--timeline-gap: var(--space-48);
--timeline-inset: var(--space-24);
}
}
@media (min-width: 768px) {
&.sm_tablet {
--timeline-dot: 12px;
--timeline-halo: 6px;
--timeline-gap: var(--space-32);
--timeline-inset: var(--space-16);
}
&.md_tablet {
--timeline-dot: 16px;
--timeline-halo: 10px;
--timeline-gap: var(--space-48);
--timeline-inset: var(--space-24);
}
}
@media (min-width: 1024px) {
&.sm_desktop {
--timeline-dot: 12px;
--timeline-halo: 6px;
--timeline-gap: var(--space-32);
--timeline-inset: var(--space-16);
}
&.md_desktop {
--timeline-dot: 16px;
--timeline-halo: 10px;
--timeline-gap: var(--space-48);
--timeline-inset: var(--space-24);
}
}
@media (min-width: 1440px) {
&.sm_wide {
--timeline-dot: 12px;
--timeline-halo: 6px;
--timeline-gap: var(--space-32);
--timeline-inset: var(--space-16);
}
&.md_wide {
--timeline-dot: 16px;
--timeline-halo: 10px;
--timeline-gap: var(--space-48);
--timeline-inset: var(--space-24);
}
}
&.stacked {
--timeline-columns: var(--timeline-dot) 1fr;
--timeline-areas: ". year" "dot title" "dot title" ". body" ". gap";
--timeline-column-gap: var(--timeline-inset);
--timeline-year-height: auto;
--timeline-year-justify: flex-start;
--timeline-year-gap: var(--space-8);
}
&.axis {
--timeline-columns: 1fr var(--timeline-dot) 1fr;
--timeline-areas: ". . ." "year dot title" "year dot title" ". . body" ". . gap";
--timeline-column-gap: var(--timeline-gap);
--timeline-year-height: 0;
--timeline-year-justify: flex-end;
--timeline-year-gap: 0;
}
&.stacked_base {
--timeline-columns: var(--timeline-dot) 1fr;
--timeline-areas: ". year" "dot title" "dot title" ". body" ". gap";
--timeline-column-gap: var(--timeline-inset);
--timeline-year-height: auto;
--timeline-year-justify: flex-start;
--timeline-year-gap: var(--space-8);
}
&.axis_base {
--timeline-columns: 1fr var(--timeline-dot) 1fr;
--timeline-areas: ". . ." "year dot title" "year dot title" ". . body" ". . gap";
--timeline-column-gap: var(--timeline-gap);
--timeline-year-height: 0;
--timeline-year-justify: flex-end;
--timeline-year-gap: 0;
}
@media (min-width: 480px) {
&.stacked_mobile {
--timeline-columns: var(--timeline-dot) 1fr;
--timeline-areas: ". year" "dot title" "dot title" ". body" ". gap";
--timeline-column-gap: var(--timeline-inset);
--timeline-year-height: auto;
--timeline-year-justify: flex-start;
--timeline-year-gap: var(--space-8);
}
&.axis_mobile {
--timeline-columns: 1fr var(--timeline-dot) 1fr;
--timeline-areas: ". . ." "year dot title" "year dot title" ". . body" ". . gap";
--timeline-column-gap: var(--timeline-gap);
--timeline-year-height: 0;
--timeline-year-justify: flex-end;
--timeline-year-gap: 0;
}
}
@media (min-width: 768px) {
&.stacked_tablet {
--timeline-columns: var(--timeline-dot) 1fr;
--timeline-areas: ". year" "dot title" "dot title" ". body" ". gap";
--timeline-column-gap: var(--timeline-inset);
--timeline-year-height: auto;
--timeline-year-justify: flex-start;
--timeline-year-gap: var(--space-8);
}
&.axis_tablet {
--timeline-columns: 1fr var(--timeline-dot) 1fr;
--timeline-areas: ". . ." "year dot title" "year dot title" ". . body" ". . gap";
--timeline-column-gap: var(--timeline-gap);
--timeline-year-height: 0;
--timeline-year-justify: flex-end;
--timeline-year-gap: 0;
}
}
@media (min-width: 1024px) {
&.stacked_desktop {
--timeline-columns: var(--timeline-dot) 1fr;
--timeline-areas: ". year" "dot title" "dot title" ". body" ". gap";
--timeline-column-gap: var(--timeline-inset);
--timeline-year-height: auto;
--timeline-year-justify: flex-start;
--timeline-year-gap: var(--space-8);
}
&.axis_desktop {
--timeline-columns: 1fr var(--timeline-dot) 1fr;
--timeline-areas: ". . ." "year dot title" "year dot title" ". . body" ". . gap";
--timeline-column-gap: var(--timeline-gap);
--timeline-year-height: 0;
--timeline-year-justify: flex-end;
--timeline-year-gap: 0;
}
}
@media (min-width: 1440px) {
&.stacked_wide {
--timeline-columns: var(--timeline-dot) 1fr;
--timeline-areas: ". year" "dot title" "dot title" ". body" ". gap";
--timeline-column-gap: var(--timeline-inset);
--timeline-year-height: auto;
--timeline-year-justify: flex-start;
--timeline-year-gap: var(--space-8);
}
&.axis_wide {
--timeline-columns: 1fr var(--timeline-dot) 1fr;
--timeline-areas: ". . ." "year dot title" "year dot title" ". . body" ". . gap";
--timeline-column-gap: var(--timeline-gap);
--timeline-year-height: 0;
--timeline-year-justify: flex-end;
--timeline-year-gap: 0;
}
}
}
</style>
<script setup lang="ts">
interface TimelineItemProps {
current?: boolean;
}
defineProps<TimelineItemProps>();
</script>
<template>
<li class="item" :class="{ current }" :aria-current="current ? 'true' : undefined">
<div class="item_year"><slot name="year"></slot></div>
<span class="item_dot" aria-hidden="true"></span>
<div class="item_title"><slot name="title"></slot></div>
<div class="item_body"><slot></slot></div>
</li>
</template>
<style scoped>
.item {
display: grid;
grid-template-columns: var(--timeline-columns);
grid-template-areas: var(--timeline-areas);
/* The title spans two twin rows: the line between them is its centre, and the
dot and the rail are placed on that line whatever the title measures. */
grid-template-rows: auto auto auto auto var(--timeline-gap);
column-gap: var(--timeline-column-gap);
&:last-child {
grid-template-rows: auto auto auto auto 0;
}
/* Two strokes per item, so the rail runs from dot to dot. The colour
is a background layer whose size animates: a scroll timeline would
freeze an animated colour on a theme switch. */
&::before,
&::after {
content: "";
grid-column: dot;
justify-self: center;
width: 2px;
background-color: var(--color-border);
background-repeat: no-repeat;
}
&::before {
grid-row: 1 / 3;
background-image: linear-gradient(var(--color-accent-secondary), var(--color-accent-secondary));
}
&::after {
grid-row: 3 / -1;
background-image: linear-gradient(var(--color-accent), var(--color-accent-secondary));
}
&:first-child::before,
&:last-child::after {
visibility: hidden;
}
@media (prefers-reduced-motion: no-preference) {
&::before,
&::after {
animation: timeline-draw linear both;
}
&:not(.current) .item_dot::after {
animation: timeline-reach linear both;
}
@supports (animation-timeline: view()) {
&::before,
&::after {
animation-timeline: view(block 0 50%);
animation-range: entry 0% entry 100%;
}
&:not(.current) .item_dot::after {
animation-timeline: view(block 0 50%);
animation-range: entry 0% entry 10%;
}
}
}
/* Axis: a zero-height line at the title's centre, so the year centres on it
without sizing the title rows. */
.item_year {
grid-area: year;
align-self: center;
display: flex;
align-items: center;
justify-content: var(--timeline-year-justify);
height: var(--timeline-year-height);
padding-bottom: var(--timeline-year-gap);
}
.item_dot {
position: relative;
z-index: var(--z-raised);
grid-area: dot;
align-self: center;
justify-self: center;
width: var(--timeline-dot);
height: var(--timeline-dot);
border: 2px solid var(--color-text-tertiary);
border-radius: var(--radius-full);
background: var(--color-bg);
/* The reached state: a layer in live colours whose opacity is what moves. */
&::after {
content: "";
position: absolute;
inset: -2px;
border-radius: inherit;
background: var(--color-accent);
opacity: 0;
}
}
.item_title {
grid-area: title;
align-self: center;
}
.item_body {
grid-area: body;
padding-top: var(--space-8);
}
&.current .item_dot {
box-shadow: 0 0 0 var(--timeline-halo) color-mix(in srgb, var(--color-accent) 40%, transparent);
&::after {
opacity: 1;
}
}
}
@keyframes timeline-draw {
from {
background-size: 100% 0;
}
to {
background-size: 100% 100%;
}
}
@keyframes timeline-reach {
to {
opacity: 1;
}
}
</style>
Props
| Prop | Type | Required | Description |
|---|---|---|---|
| size | "sm" | "md" | ResponsiveSize | required | Size of the dot, the halo and the gaps between items. Fixed or responsive. |
| layout | "stacked" | "axis" | ResponsiveLayout | required | Stacked (year on top, rail on the left) or axis (rail down the middle, year on one side and content on the other). Fixed or responsive. |
| children | React.ReactNode | required | The items, one <TimelineItem> each. |
| TimelineItem.year | React.ReactNode | required | The item's date or label; in axis it sits across the rail. |
| TimelineItem.title | React.ReactNode | required | The item's title. The dot centres on it, whatever its size. |
| TimelineItem.current | boolean | — | Marks the current item: a filled dot with a halo (aria-current). |
| TimelineItem.children | React.ReactNode | required | Content under the title. |