In this post, we are going to make a React component that will be available as an NPM module so that we could use it in different projects. We will cover a lot of concepts here, and you find this story useful if you think about creating a chart component or build a library.
First, we are going to bootstrap the project and create the specification for our library. Then we will implement the demo page that will show the functionality of our component and a function for test data generation. Ather that we can start the development of the bar chart. First, we will create a layout of the component. And then we will add bars, labels, and custom scroll. Finally, we will deploy a demo page and publish an NPM module.
Create-react-app is the best tool for bootstrapping the front-end project, and I always use it for new apps. However, since we want to make an NPM module, we need a different starter. After some research, I found a tool named create-react-library that using create-react-app to run demo and rollup to build the library.
To start a new project, we will type these commands:
npm install -g create-react-library
create-react-library increaser-Timer
cd increaser-TimerAfter initializing the project, we will install the only extra library we will need.
npm install --save styled-componentsAnd add additional fields in the rollup.config.js file.
    ...
    external: ['styled-components'],
    globals: { 'styled-components': 'styled' }
}
First, we will run rollup to watch our src module and automatically recompile it into dist whenever we make changes.
npm startThe second part will be running the example create-react-app that’s linked to the local version of our module. In another tab:
cd example
npm startYou may notice that when you are using third-party React components, your code editor autofill the names of parameters, and you can go to the definition of the component to see properties and their types. It happens because libraries export a file with declarations. Let’s also create one.
import React from 'react'
export interface Theme {
  mainColor: string;
  scrollerBackgroundColor: string;
  labelFontSize: number;
}
export interface Item {
  value: number;
  color: string;
}
export interface Bar {
  label?: string;
  items: Item[]
}
export interface BarChartProps {
  bars: Bar[];
  barWidth: number;
  barSpace: number;
  centerBarIndex: number;
  onBarSelect: (centerBarIndex: number) => void;
  selectCenterBarOnScroll: boolean;
  showScroll: boolean;
  theme: Theme;
}
export declare class BarChart extends React.Component<BarChartProps, any> {}To make this file be published on NPM together with the code, we need to edit package.json.
{
    ...
    "typings": "./index.d.ts",
    "files": ["dist", "index.d.ts"],
    ...
}With this file in place, when we are using the library, we are going to see hint like this.
When we are using create-react-library, we have two projects. One with demo page that we are using for development of our component and other is library itself. First, we are going to develop a demo page with all parameters available to change in UI, so that we can comfortably develop and test our library.
Demo page will have a container for chart and panel where we can change parameters of the library. To change them we need two components: Switch for boolean parameters, and Slider for parameters such as bar width and space. And since they only required for demo page, we will install material-ui in the demo project.
cd example
npm install --save @material-ui/core @material-ui/libimport React from "react"
import styled from "styled-components"
import { Switch } from "@material-ui/core"
import { Slider } from "@material-ui/lab"
import { BarChart } from "increaser-charts"
import { getMockBars } from "./mock"
const Page = styled.div`
  min-height: 100vh;
  width: 100%;
  background-color: #2c3e50;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
`
const Container = styled.div`
  width: 80%;
  padding: 20px;
  box-shadow: 0 19px 38px rgba(0, 0, 0, 0.3), 0 15px 12px rgba(0, 0, 0, 0.22);
  border-radius: 5px;
  display: flex;
`
const Wrapper = styled(Container)`
  height: 60vh;
  align-items: center;
  justify-content: center;
  @media (max-width: 640px) {
    width: 100%;
    box-shadow: none;
  }
`
const Panel = styled(Container)`
  flex-direction: column;
`
const PanelRow = styled.div`
  width: 100%;
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  justify-content: space-around;
`
const ParamContainer = styled.div`
  padding: 10px;
  margin: 10px;
  display: flex;
  border-radius: 5px;
  border: 1px solid gold;
  height: 80px;
  width: 280px;
`
const BoolParam = styled(ParamContainer)`
  flex-direction: row;
  align-items: center;
  justify-content: space-between;
`
const RangeParam = styled(ParamContainer)`
  flex-direction: column;
  justify-content: space-around;
`
const Param = styled.h4`
  color: white;
`
export default class App extends React.Component {
  constructor(props) {
    super(props)
    const barsNumber = 60
    const barsWithLabels = true
    this.state = {
      centerBarIndex: undefined,
      barWidth: 35,
      barSpace: 8,
      barsNumber,
      barsWithLabels,
      bars: getMockBars(barsNumber, barsWithLabels),
      showScroller: true,
      selectCenterBarOnScroll: true,
    }
  }
  render() {
    const {
      bars,
      barWidth,
      barSpace,
      centerBarIndex,
      selectCenterBarOnScroll,
      showScroller,
      barsWithLabels,
      barsNumber,
    } = this.state
    return (
      <Page>
        <Wrapper>
          <BarChart
            bars={bars}
            barWidth={barWidth}
            barSpace={barSpace}
            centerBarIndex={centerBarIndex}
            onBarSelect={(centerBarIndex) => this.setState({ centerBarIndex })}
            selectCenterBarOnScroll={selectCenterBarOnScroll}
            showScroller={showScroller}
          />
        </Wrapper>
        <Panel>
          <PanelRow>
            <BoolParam>
              <Param>select center bar on scroll: </Param>
              <Switch
                checked={selectCenterBarOnScroll}
                onChange={() =>
                  this.setState({
                    selectCenterBarOnScroll: !selectCenterBarOnScroll,
                  })
                }
              />
            </BoolParam>
            <BoolParam>
              <Param>bars with labels: </Param>
              <Switch
                checked={barsWithLabels}
                onChange={() => this.toggleBarsWithLabels()}
              />
            </BoolParam>
            <BoolParam>
              <Param>show scroll: </Param>
              <Switch
                checked={showScroller}
                onChange={() => this.setState({ showScroller: !showScroller })}
              />
            </BoolParam>
          </PanelRow>
          <PanelRow>
            <RangeParam>
              <Param>bar width: {barWidth}</Param>
              <Slider
                value={barWidth}
                min={5}
                max={300}
                step={1}
                onChange={(_, barWidth) => this.setState({ barWidth })}
              />
            </RangeParam>
            <RangeParam>
              <Param>bar space: {barSpace}</Param>
              <Slider
                value={barSpace}
                min={5}
                max={300}
                step={1}
                onChange={(_, barSpace) => this.setState({ barSpace })}
              />
            </RangeParam>
            <RangeParam>
              <Param>bars number: {barsNumber}</Param>
              <Slider
                value={barsNumber}
                min={0}
                max={300}
                step={1}
                onChange={(_, barsNumber) => this.changeBarNumber(barsNumber)}
              />
            </RangeParam>
          </PanelRow>
        </Panel>
      </Page>
    )
  }
  toggleBarsWithLabels = () => {
    const { barsNumber, barsWithLabels } = this.state
    const newBarsWithLabels = !barsWithLabels
    this.setState({
      barsWithLabels: newBarsWithLabels,
      bars: getMockBars(barsNumber, newBarsWithLabels),
    })
  }
  changeBarNumber = (barsNumber) => {
    const { barsWithLabels } = this.state
    const bars = getMockBars(barsNumber, barsWithLabels)
    this.setState({ bars, barsNumber })
  }
}In the constructor of the demo page and on change of some parameters we will create mock data. The function will receive the number of bars we want to generate and should they include a label or not. To define the height of the bar we will use a function, that will return normally distributed random number.
const MIN_TOTAL_HEIGHT = 0
const MAX_TOTAL_HEIGHT = 10
const MIN_SUBBAR_HEIGHT = 1
const COLORS = [
  "#5f27cd",
  "#9b59b6",
  "#33d9b2",
  "#F9A825",
  "#EF6C00",
  "#27ae60",
  "#f368e0",
  "#00CC00",
  "#283593",
  "#e74c3c",
  "#FC427B",
]
const getGaussianRandomNumber = () =>
  new Array(6)
    .fill(0)
    .map(() => Math.random())
    .reduce((acc, n) => acc + n, 0) / 6
const getMockBarItems = () => {
  const randNumber = getGaussianRandomNumber()
  const height =
    MIN_TOTAL_HEIGHT + randNumber * (MAX_TOTAL_HEIGHT - MIN_TOTAL_HEIGHT)
  const getSubbars = (heightLeft, freeColors, result) => {
    if (heightLeft === 0) return result
    if (freeColors.lenght < 2) {
      const subbar = {
        value: heightLeft,
        color: freeColors[0],
      }
      return [...result, subbar]
    }
    const getSubbaHeight = () => {
      const randomHeight = Math.random() * heightLeft
      const height =
        randomHeight < MIN_SUBBAR_HEIGHT
          ? randomHeight + MIN_SUBBAR_HEIGHT
          : randomHeight
      const totalHeightAfter = heightLeft - height
      if (totalHeightAfter < MIN_SUBBAR_HEIGHT) return heightLeft
      return height
    }
    const value = getSubbaHeight()
    const [color, ...newFreeColors] = freeColors
    const subbar = {
      value,
      color,
    }
    return getSubbars(heightLeft - value, newFreeColors, [...result, subbar])
  }
  return getSubbars(height, COLORS, [])
}
export const getMockBars = (number, withLabels = true) => {
  const bars = new Array(number).fill(0).map((_, index) => {
    const items = getMockBarItems()
    if (!withLabels) return { items }
    const getLabel = () => {
      if (index + 1 === number) return "Today"
      const today = new Date()
      today.setDate(today.getDate() - (number - index - 1))
      return `${today.getMonth() + 1}/${today.getDate()}`
    }
    return {
      items,
      label: getLabel(),
    }
  })
  return bars
}We will start development of BarChart by creating the basic layout of the component. Let’s start with index.js. At the root component, we are rendering DataContainer that contains Bars and Labels and Scroller container. Before rendering them, we need to take the width of the root container since this value required for Bars, Labels, and Scroller.
index.js
render() {
  const { theme, showScroller, barWidth, barSpace, bars, centerBarIndex, onBarSelect } = this.props
  const { width, offset, oldOffset, height } = this.state
  const barTotalWidth = barWidth + barSpace
  const totalWidth = bars.length * barTotalWidth
  const getStartIndex = () => {
    const startIndex = Math.floor((totalWidth - width - oldOffset - (offset > oldOffset ? offset - oldOffset : 0)) / barTotalWidth)
    if (startIndex < 0) return 0
    return startIndex
  }
  const startIndex = getStartIndex()
  const Content = () => {
    if (!width) return null
    const dataConainerProps = { barTotalWidth, width, offset, oldOffset, totalWidth, startIndex }
    const Labels = () => {
      return (
        <LabelsContainer>
        </LabelsContainer>
      )
    }
    const lastIndex = Math.ceil((totalWidth + oldOffset + (offset < oldOffset ? oldOffset - offset : 0)) / barTotalWidth)
    const slicedBars = bars.slice(startIndex, lastIndex)
    const highest = bars.map(b => b.items).reduce((acc, bar) => {
      const height = sum(bar)
      return height > acc ? height : acc
    }, 0)
    const Bars = () => {
      if (!height) return null
      const barCommonProps = { startIndex, height, barWidth, barSpace, centerBarIndex, onBarSelect, highest}
      return slicedBars.map(({ items }, index) =>(
        <Bar
          {...barCommonProps}
          bar={items}
          index={index}
          key={index}
        />
      ))
    }
    return (
      <React.Fragment>
        <DataContainer {...dataConainerProps}>
          <BarsView ref={el => this.barsContainer = el}>
            <Bars/>
          </BarsView>
          <Labels/>
        </DataContainer>
        {showScroller && <Scroller/>}
      </React.Fragment>
    )
  }
  return (
    <ThemeProvider theme={{ ...DEFAULT_THEME, ...theme }}>
      <RootContainer ref={el => this.rootContainer = el }>
        <Content/>
      </RootContainer>
    </ThemeProvider>
  )
}
static getDerivedStateFromProps(nextProps, prevState) {
  const { width, offset, scrolling } = prevState
  if (!width) return null
  const { centerBarIndex, barWidth, barSpace, bars } = nextProps
  const bar = barWidth + barSpace
  const totalWidth = bars.length * bar + barSpace
  const getNewOffsets = () => {
    if (centerBarIndex !== undefined && !scrolling) {
      if (totalWidth < width) {
        return {
          oldOffset: 0,
          offset: 0
        }
      }
      const offsetToCenter = totalWidth - bar * centerBarIndex - (width + bar) / 2
      const getOffset = () => {
        if (offsetToCenter < 0) return 0
        if (offsetToCenter + width > totalWidth) return totalWidth - width
        return offsetToCenter
      }
      return {
        offset: getOffset(),
        oldOffset: offset
      }
    }
  }
  return {
    ...prevState,
    ...getNewOffsets(),
    totalWidth
  }
}Bars and labels will be placed absolutely inside of DataContainer. The important point is that we render only part of data that will be visible to a user.
data-container.js
import React from 'react'
import styled from 'styled-components'
import { animationStyle } from './styles';
const Wrapper = styled.div`
  width: 100%;
  height: 100%;
  position: relative;
  overflow: hidden;
`
const DataContainer = styled.div`
  height: 100%;
  position: absolute;
  display: flex;
  flex-direction: column;
  ${animationStyle}
`
export default ({
  barTotalWidth,
  width,
  offset,
  oldOffset,
  totalWidth,
  startIndex,
  children
}) => {
  const left = totalWidth < width ? (width - totalWidth) / 2 : width + oldOffset - totalWidth + startIndex * barTotalWidth
  const containerWidth = totalWidth - startIndex * barTotalWidth
  const animationOffset = offset - oldOffset
  const style = { left, width: containerWidth }
  return (
    <Wrapper>
      <DataContainer offset={animationOffset} style={style}>
        {children}
      </DataContainer>
    </Wrapper>
  )Check this commit to see all changes.
We will use SVG to render bars. This way each bar will be represented as a rect element with an appropriate color.
bar.js
import React from "react"
import { sum } from "../../utils"
export default ({
  bar,
  index,
  height,
  barWidth,
  barSpace,
  centerBarIndex,
  onBarSelect,
  highest,
  startIndex,
}) => {
  const barTotalWidth = barWidth + barSpace
  const realIndex = index + startIndex
  const x = barTotalWidth * index + barSpace
  const valueToHeight = (value) => (value * height) / highest
  const Subbar = ({ value, color, valueBefore }) => {
    const rectHeight = valueToHeight(value)
    const y = height - valueToHeight(valueBefore) - rectHeight
    return (
      <rect
        opacity={realIndex === centerBarIndex ? 1 : 0.6}
        x={x}
        y={y}
        width={barWidth}
        height={height}
        fill={color}
      />
    )
  }
  const Selectable = () => (
    <rect
      onTouchEnd={(e) => {
        onBarSelect(realIndex)
        e.preventDefault()
      }}
      onClick={() => onBarSelect(realIndex)}
      cursor={"pointer"}
      x={x - barSpace / 2}
      width={barTotalWidth}
      y={0}
      height={height}
      fill={"transparent"}
    />
  )
  return (
    <g>
      {bar.map(({ value, color }, index) => {
        const valueBefore = sum(bar.slice(0, index))
        const props = { value, color, valueBefore, key: index }
        return <Subbar {...props} />
      })}
      {onBarSelect && <Selectable />}
    </g>
  )
}Now, having Bar component, we can update index.js. We will add code to render bars and to calculate new offset that used for animation we will add getDerivedStateFromProps method.
Check this commit to see all changes.
Before rendering labels, we will check that bars have at least one label. If there are some labels, we will place them each one right under the appropriate bar. If we are selecting some bar, we will change its styles appropriately.
Check this commit to see all changes.
The last feature we will have in the library is Scroller. This element will show which part of the bars currently viewed. Also, we can use it to scroll bars. When user mouseDown on scroll we will call appropriate action and then we will watch for document mouseUp event to tell root component about changes on x. Then in root component, we will apply logic to change offset and therefore change the position of both scroll and bars.
import React from "react"
import styled from "styled-components"
import { animationStyle } from "./styles"
const Container = styled.div`
  position: relative;
  height: 20px;
  width: 100%;
  display: flex;
  align-items: center;
`
const Line = styled.div`
  position: absolute;
  width: 100%;
  height: 6px;
  background-color: ${(props) => props.theme.scrollerBackgroundColor};
`
const Position = styled.div`
  cursor: pointer;
  position: absolute;
  height: 6px;
  background-color: ${(props) => props.theme.mainColor};
  border-radius: 3px;
  &:hover {
    height: 8px;
    border-radius: 4px;
  }
  ${animationStyle}
`
export default class Scroller extends React.Component {
  render() {
    const { totalWidth, offset, oldOffset, width } = this.props
    const getMargin = (offset) =>
      ((totalWidth - (offset + width)) / totalWidth) * 100
    const periodMargin = getMargin(oldOffset)
    if (periodMargin < 0) return null
    const periodWidth = (width * 100) / totalWidth
    const animationOffset = getMargin(offset) - periodMargin
    const positionStyle = {
      width: `${periodWidth}%`,
      marginLeft: `${periodMargin}%`,
    }
    return (
      <Container>
        <Line />
        <Position
          offset={(animationOffset * width) / 100}
          style={positionStyle}
          onTouchStart={(e) => e.preventDefault()}
          onDragStart={(e) => e.preventDefault()}
          onMouseDown={this.onMouseDown}
        />
      </Container>
    )
  }
  shouldComponentUpdate(nextProps) {
    return nextProps !== this.props
  }
  componentDidMount() {
    document.addEventListener("mouseup", this.onMouseUp)
    document.addEventListener("mousemove", this.onMouseMove)
  }
  componentWillUnmount() {
    document.removeEventListener("mouseup", this.onMouseUp)
    document.removeEventListener("mousemove", this.onMouseMove)
  }
  onMouseDown = () => {
    this.props.onDragStart()
  }
  onMouseMove = (e) => {
    const { scrolling, onDrag } = this.props
    if (scrolling && e.movementX) {
      onDrag(e.movementX)
    }
  }
  onMouseUp = () => {
    const { scrolling, onDragEnd } = this.props
    if (scrolling) {
      onDragEnd()
    }
  }
}Final index.js:
render() {
  const { theme, showScroller, barWidth, barSpace, bars, centerBarIndex, onBarSelect } = this.props
  const { width, offset, oldOffset, height, scrolling, totalWidth } = this.state
  const barTotalWidth = barWidth + barSpace
  const getStartIndex = () => {
    const startIndex = Math.floor((totalWidth - width - oldOffset - (offset > oldOffset ? offset - oldOffset : 0)) / barTotalWidth)
    if (startIndex < 0) return 0
    return startIndex
  }
  const startIndex = getStartIndex()
  const Content = () => {
    if (!width) return null
    const dataConainerProps = { barTotalWidth, width, offset, oldOffset, totalWidth, startIndex }
    const Labels = () => {
      const labels = slicedBars.map(b => b.label)
      if (labels.every(l => !l)) return null
      return (
        <LabelsContainer>
          {labels.map((label, index) => (
            <Label
              width={barWidth}
              space={barSpace}
              selected={centerBarIndex === index + startIndex}
              key={index + startIndex}
            >
              {label}
            </Label>
          ))}
        </LabelsContainer>
      )
    }
    const lastIndex = Math.ceil((totalWidth + oldOffset + (offset < oldOffset ? oldOffset - offset : 0)) / barTotalWidth)
    const slicedBars = bars.slice(startIndex, lastIndex)
    const highest = bars.map(b => b.items).reduce((acc, bar) => {
      const height = sum(bar)
      return height > acc ? height : acc
    }, 0)
    const Bars = () => {
      if (!height) return null
      const barCommonProps = { startIndex, height, barWidth, barSpace, centerBarIndex, onBarSelect, highest}
      return slicedBars.map(({ items }, index) =>(
        <Bar
          {...barCommonProps}
          bar={items}
          index={index}
          key={index}
        />
      ))
    }
    const scrollerProps = {
      totalWidth,
      width,
      offset,
      oldOffset,
      scrolling,
      onDragStart: () => this.setState({ scrolling: true, oldOffset: this.state.offset }),
      onDrag: this.onScroll,
      onDragEnd: () => this.setState({ scrolling: false })
    }
    return (
      <React.Fragment>
        <DataContainer {...dataConainerProps}>
          <BarsView ref={el => this.barsContainer = el}>
            <Bars/>
          </BarsView>
          {bars.length > 0 && <Line/>}
          <Labels/>
        </DataContainer>
        {showScroller && <Scroller {...scrollerProps} />}
      </React.Fragment>
    )
  }
  return (
    <ThemeProvider theme={{ ...DEFAULT_THEME, ...theme }}>
      <RootContainer ref={el => this.rootContainer = el }>
        <Content/>
      </RootContainer>
    </ThemeProvider>
  )
}
onScroll = (movementX) => {
    const { width, offset, totalWidth } = this.state
    const { barWidth, barSpace, bars, selectCenterBarOnScroll, centerBarIndex, onBarSelect } = this.props
    const additionalOffset = (totalWidth / width) * movementX
    const getOffset = () => {
      const newOffset = offset - additionalOffset
      if (newOffset < 0) return 0
      if (newOffset + width > totalWidth) return totalWidth - width
      return newOffset
    }
    const newOffset = getOffset(0)
    this.setState({ offset: newOffset, oldOffset: newOffset })
    if (selectCenterBarOnScroll) {
      const center = totalWidth - newOffset - width / 2
      const newCenterBarIndex = bars.findIndex((_, index) => (index * (barWidth + barSpace)) >= center) - 1
      if (centerBarIndex !== newCenterBarIndex) {
        onBarSelect(newCenterBarIndex)
      }
    }
  }Check this commit to see all changes.
Now we can deploy our library to NPM and demo to GitHub pages by typing these commands:
npm publish --access-public
npm run deployI already integrated this chart in my app — increaser.org. Check it out!