What i want:
You know something is done well when you don’t even think about it. I caught myself playing with the coinbase price chart over the past few days without realizing it (not a good idea to check every day, but bull runs will do that to you).
I didn’t really have a good idea of how they did it as well (repeating linear gradient for the dots? each line is a div? how’d they change opacity based on cursor position? - and only for half of the chart?).
Naturally I whip open inspect element and to my surprise it’s two svgs and a div.
- a svg for the sparkline
- another svg for the dotted line you move around
- a div to apply an opacity filter to half the svg as you move the dotted line
Container #
The first thing to do is create the container for the graph with the following properties:
- width is 100vw on mobile, 620px on desktop
- height breakpoints of 320px, 120px, and 100px on md, sm and xs devices
- it’s children can offset themselves relative to it
(position: relative)
Vertical Line & Date #
There is a vertical line that while in the container follows the cursors X position. The date is centered above the vertical line and follows its movements.
To do this we need to set up some JavaScript to track the cursors position and if it’s inside the chart area.
The vertical line is a svg, whose parent div’s transform: translateX(_px)
style property is updated as the X position of the cursor changes, same goes for the label above it (but the label’s x position is clamped to a range to prevent it sliding outside of the box).
Chart = {
$: {
cursorInChart: false,
chart: document.getElementById("chart-container"),
chartLine: document.getElementById("chart-line-container"),
chartLineLabel: document.getElementById("chart-line-label"),
},
updateChartLine(xPos){
const rect = this.$.chart.getBoundingClientRect()
this.$.chartLine.style.transform = `translateX(${Math.min(xPos+1, rect.width-1)}px)`
},
updateChartLineLabel(xPos, labels) {
const chartRect= this.$.chart.getBoundingClientRect()
const posAsIndex = Math.floor(xPos * (quotes.length / chartRect.width))
this.$.chartLineLabel.innerText= new Date(labels[Math.max(posAsIndex, 0)]).toDateString()
const labelRect= this.$.chartLineLabel.getBoundingClientRect()
const BUFFER = 4
xPos = xPos - (labelRect.width / 2)
// clamp label's x pos
xPos = Math.max(BUFFER, xPos)
xPos = Math.min(chartRect.width - labelRect.width - BUFFER, xPos)
this.$.chartLineLabel.style.transform = `translateX(${xPos}px)`
},
init() {
// toggle cursorInChart variable
["mouseenter", "mouseleave"].forEach((eventType) =>
this.$.chart.addEventListener(eventType, () => {
this.$.cursorInChart = !this.$.cursorInChart
})
)
// on mouse move update dynamic elements
this.$.chart.addEventListener("mousemove", (event) => {
if (!this.$.cursorInChart) return;
const xPos = event.clientX - this.$.chart.getBoundingClientRect().left
this.updateChartLine(xPos)
this.updateChartLineLabel(xPos)
})
}
}
Chart.init()
Chart Line #
The chart line is created based on a JSON list containing date/price pairs. This is achieved again using a svg, but this time it’s generated based on the data. We can do this by creating a function that accepts the points and then uses information about the chart element to create the svg.
The cool part here is when we create the path
variable, we start 1 unit from the left 1M, and then begin drawing lines every stepSize
which start where the last line stopped.
To create the dotted background underneath the line we use the same path
variable but make it a full shape by adding two new points; the bottom left and right corner. Then we use a pattern
from the defs
section to fill this path with the dotted design.
buildSVG(points) {
const {height, width} = this.$.chart.getBoundingClientRect()
const stepSize = width / points.length
const verticalScale = height/ Math.max(...points)
let path = `M1, ${height - points[0] * verticalScale}\n`
for (let i = 1; i < points.length; i++) {
const x = i * stepSize
const y = height - (points[i] * verticalScale)
path += `L${x},${y}\n`
}
return `
<svg width="${width}" height="${height}">
<defs>
<pattern height="4" width="4" x="0" y="0" id="dots" patternUnits="userSpaceOnUse" >
<g>
<circle cx="1" cy="1" fill="#222222" fill-opacity="0.2" r="1" />
</g>
</pattern>
</defs>
<g>
<!-- line -->
<path d="${path}" fill="none" stroke="black" stroke-width="2" />
<!-- dots pattern below line, add bottom corners to fill -->
<path d="${path + `L${width},${height} L0,${height}`}" stroke="none" stroke-width="2" fill="url(#dots)"/>
</g>
</svg>
`
}
Opacity Filter #
The final detail is the opacity reduction to the right of the line that updates as the explores the graph - to achieve this a white div with 80% opacity slides on top of the svg. I thought this was a pretty interesting way to do initially i had guessed they modified the svg based on the current x Position but this is simpler.
PS #
- google’s
Market Summary
feature uses the same svg technique!
Code: #
the JS
Chart = {
$: {
cursorInChart: false,
chart: document.getElementById("chart-container"),
chartLine: document.getElementById("chart-line-container"),
chartLineLabel: document.getElementById("chart-line-label"),
chartSvg: document.getElementById("chart-svg"),
mask: document.getElementById("mask")
},
buildSVG(points) {
const {height, width} = this.$.chart.getBoundingClientRect()
const stepSize = width / points.length
const verticalScale = height/ Math.max(...points)
let path = `M1, ${height - points[0] * verticalScale}\n`
for (let i = 1; i < points.length; i++) {
const x = i * stepSize
const y = height - (points[i] * verticalScale)
path += `L${x},${y}\n`
}
return `
<svg width="${width}" height="${height}">
<defs>
<pattern height="4" width="4" x="0" y="0" id="dots" patternUnits="userSpaceOnUse" >
<g>
<circle cx="1" cy="1" fill="#222222" fill-opacity="0.2" r="1" />
</g>
</pattern>
</defs>
<g>
<!-- line -->
<path d="${path}" fill="none" stroke="black" stroke-width="2" />
<!-- dots pattern below line, add bottom corners to fill -->
<path d="${path + `L${width},${height} L0,${height}`}" stroke="none" stroke-width="2" fill="url(#dots)"/>
</g>
</svg>
`
},
updateChartLine(xPos){
const rect = this.$.chart.getBoundingClientRect()
this.$.chartLine.style.transform = `translateX(${Math.min(xPos+1, rect.width-1)}px)`
},
updateChartLineLabel(xPos, labels) {
const chartRect= this.$.chart.getBoundingClientRect()
const posAsIndex = Math.floor(xPos * (quotes.length / chartRect.width))
this.$.chartLineLabel.innerText= new Date(labels[Math.max(posAsIndex, 0)]).toDateString()
const labelRect= this.$.chartLineLabel.getBoundingClientRect()
const BUFFER = 4
xPos = xPos - (labelRect.width / 2)
xPos = Math.max(BUFFER, xPos)
xPos = Math.min(chartRect.width - labelRect.width - BUFFER, xPos)
this.$.chartLineLabel.style.transform = `translateX(${xPos}px)`
},
updateMask(xPos) {
const chartRect= this.$.chart.getBoundingClientRect()
this.$.mask.style.transform = `translateX(${chartRect.width + 2 - (chartRect.width - xPos)}px)`
},
addEventListeners() {
// toggle cursorInChart variable
["mouseenter", "mouseleave"].forEach((eventType) =>
this.$.chart.addEventListener(eventType, () => {
this.$.cursorInChart = !this.$.cursorInChart
})
)
// on mouse move update dynamic elements
this.$.chart.addEventListener("mousemove", (event) => {
if (!this.$.cursorInChart) return;
const xPos = event.clientX - this.$.chart.getBoundingClientRect().left
this.updateChartLine(xPos)
this.updateChartLineLabel(xPos, quotes.map(quote => quote.timestamp))
this.updateMask(xPos)
})
window.addEventListener("resize", () => {
this.$.chartSvg.innerHTML = this.buildSVG(quotes.map(quote => parseFloat(quote.price)))
})
},
init() {
this.addEventListeners()
this.$.chartSvg.innerHTML = this.buildSVG(quotes.map(quote => parseFloat(quote.price)))
}
}
Chart.init()
the HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Coinbase Chart</title>
<style>
html {
overflow-y: scroll;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: -apple-system, "system-ui", "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
color: #222222;
}
.container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
width: 100%;
}
.chart-container{
width: 100%;
position: relative;
height: 100px;
overflow: hidden;
}
.chart-line-container {
position: absolute;
height: 100%;
}
.chart-line-label {
width: 100%;
padding-bottom: 1.25em;
display: inline-flex;
flex-direction: column;
}
.mask {
opacity: 60%;
background-color: rgb(255,255,255)
}
@media screen and (width >= 640px) {
.chart-container {
height: 120px;
}
}
@media screen and (width >= 768px) {
.chart-container, .chart-line-label {
max-width: 612px;
}
.chart-container {
height: 320px;
}
}
</style>
</head>
<body>
<main>
<div id="container" class="container">
<div class="chart-line-label">
<span id="chart-line-label" style="transform: translateX(0px); position: absolute;display: inline-flex; text-align: end; ">Current Date</span>
</div>
<div id="chart-container" class="chart-container">
<div id="chart-svg" style="position: absolute; pointer-events: none" ></div>
<div id="chart-line-container" class="chart-line-container" style="transform: translateX(0px)">
<svg
width="2px"
height="100%"
xmlns="http://www.w3.org/2000/svg"
>
<line x1="0" x2="0" y1="0" y2="100%"
stroke-dasharray="2,4" stroke-width="2" stroke="#222222"/>
</svg>
</div>
<div id="mask" class="mask" style="position: absolute; width: 100%; height: 100%; pointer-events: none; transform: translateX(100%);">
</div>
</div>
</div>
</main>
<script src="script.js" defer></script>
</body>
</html>