Build a Guitar Scale Visualization App with React, TypeScript, and Next.js

Build a Guitar Scale Visualization App with React, TypeScript, and Next.js

December 15, 2024

25 min read

Build a Guitar Scale Visualization App with React, TypeScript, and Next.js

Introduction

In this post, we'll create an app for guitarists to visualize scales on the fretboard using React, TypeScript, and NextJS. You can check out the final result here and explore the codebase here. To kick things off, we'll use the RadzionKit starter, which provides a comprehensive set of components and utilities designed to streamline React app development and boost productivity.

E Minor Scale on Guitar Fretboard
E Minor Scale on Guitar Fretboard

Motivation and Inspiration

I've always had a guitar and often come up with riffs and melodies, but I struggled to develop them further because I didn’t know any music theory. To change that, I picked up a book called Fretboard Theory to learn the basics. One of the first topics it covers is the layout of notes on the fretboard, along with scales and pentatonics. This inspired me to build an app where you can visualize all of this on a fretboard, making it easy to explore different patterns in one convenient place.

Features of the Guitar Scale Visualization App

Our app will feature two main views: a home page that displays all the notes on the fretboard and a scale page where you can select a root note and a scale. On the scale page, you'll also have the option to toggle between viewing the full scale or its pentatonic version.

Ensuring SEO-Friendly Pages

While this app could be built as a React single-page application, that approach would negatively impact SEO. Instead, it's better to create proper pages for each pattern. We have two options: server-side rendering or generating static pages. Since the app will only require around 200 pages, generating static pages is a great choice. It's cost-effective because we don't need to pay for a server, and the pages can be served through a CDN for free or minimal cost.

Implementing the Home Page

All notes on the Guitar Fretboard
All notes on the Guitar Fretboard

Let's start by implementing the home page, which we'll name NotesPage.

import { VStack } from "@lib/ui/css/stack"
import { NotesPageTitle } from "./NotesPageTitle"
import { NotesPageContent } from "./NotesPageContent"
import { PageContainer } from "../layout/PageContainer"

export const NotesPage = () => {
  return (
    <PageContainer>
      <VStack gap={80}>
        <NotesPageTitle />
        <NotesPageContent />
      </VStack>
    </PageContainer>
  )
}

We'll wrap the home page with a PageContainer component, which uses the centeredContentColumn and verticalPadding CSS utilities from RadzionKit. These utilities ensure the content has responsive horizontal padding, a maximum width of 1600px, and vertical padding of 80px.

import { centeredContentColumn } from "@lib/ui/css/centeredContentColumn"
import { verticalPadding } from "@lib/ui/css/verticalPadding"
import styled from "styled-components"

export const PageContainer = styled.div`
  ${centeredContentColumn({
    contentMaxWidth: 1600,
  })}

  ${verticalPadding(80)}
`

Adding Meta Tags and Titles

Next, we need to create a proper title to improve our chances of ranking on Google. We'll display an <h1> element for the main heading and include a PageMetaTags component to set the page's title and description meta tags.

import { Text } from "@lib/ui/text"
import { PageMetaTags } from "@lib/next-ui/metadata/PageMetaTags"

export const NotesPageTitle = () => {
  const pageTitle = `All Notes on the Guitar Fretboard`
  const title = `All Notes on the Guitar Fretboard | Interactive Guitar Fretboard Chart`
  const description = `Explore all the notes on the guitar fretboard with our interactive diagram. Visualize every note across 15 frets and 6 strings to enhance your guitar learning.`

  return (
    <>
      <Text centerHorizontally weight={800} size={32} color="contrast" as="h1">
        {pageTitle}
      </Text>
      <PageMetaTags title={title} description={description} />
    </>
  )
}

Leveraging Context and AI Assistance for Better Results

To generate that copy, I used ChatGPT. However, for AI to truly understand the product, it needs context. To address this, I always maintain a file called context.md that contains all the raw information about the project. Whenever I need to ask the AI something, I start by sharing this context first. While it takes time to write and maintain initially, it saves a lot of effort in the long run. You don’t have to explain the project repeatedly, and with better context, the AI delivers more accurate and relevant results.

You will be helping me with tasks related to this product. Read more about it below and reply with "Yes" if you understand the product.

This app allows you to view scales and pentatonics on a guitar fretboard. At the top of the page, there are three controls:

- **Root Note:** Select the root note of the scale. Options include all 12 notes.
- **Scale:** Select the scale. Options include Major, Minor, Blues, Dorian, Mixolydian, Phrygian, Harmonic Minor, or Melodic Minor.
- **Scale Type:** Choose whether to view the whole scale or just the pentatonic version.

Below the controls, you will see the fretboard with the notes of the selected scale. The fretboard consists of 15 frets with open notes and 6 strings. Each note is outlined with a distinct color and labeled with the note name inside the circle.

When the pentatonic scale is selected, the app also displays 5 pentatonic patterns. Each pattern is shown on a dedicated fretboard, progressing from the first to the fifth pattern.

The URL pattern is `/[scaleType]/[rootNote]/[scale]`. For example, `/pentatonic/e/minor` displays the pentatonic scale with E as the root note in the minor scale.

On the index page `/`, the app shows all the notes on the fretboard.

# Tech Stack

The app code is contained within a TypeScript monorepo. It is built with NextJS. The app does not use server-side rendering and instead relies on static site generation.

Building the Fretboard Component

The content of our NotesPage is straightforward: a fretboard displaying all the notes. We use the Fretboard component as the container and pass the notes as its children.

import { range } from "@lib/utils/array/range"
import { chromaticNotesNumber, isNaturalNote } from "@product/core/note"
import { Fretboard } from "../guitar/fretboard/Fretboard"
import { stringsCount, tuning, visibleFrets } from "../guitar/config"
import { Note } from "../guitar/fretboard/Note"

export const NotesPageContent = () => {
  return (
    <Fretboard>
      {range(stringsCount).map((string) => {
        const openNote = tuning[string]
        return range(visibleFrets + 1).map((index) => {
          const note = (openNote + index) % chromaticNotesNumber
          const fret = index === 0 ? null : index - 1

          return (
            <Note
              key={`${string}-${index}`}
              string={string}
              fret={fret}
              value={note}
              kind={isNaturalNote(note) ? "regular" : "secondary"}
            />
          )
        })
      })}
    </Fretboard>
  )
}

We use the Neck component as the container for our fretboard. It's a flexbox row element with a fixed height and a relative position.

import { toSizeUnit } from "@lib/ui/css/toSizeUnit"
import styled from "styled-components"
import { fretboardConfig } from "./config"
import { getColor } from "@lib/ui/theme/getters"
import { range } from "@lib/utils/array/range"
import { String } from "./String"
import { Fret } from "./Fret"
import { getFretMarkers } from "@product/core/guitar/fretMarkers"
import { FretMarkerItem } from "./FretMarkerItem"
import { hStack } from "@lib/ui/css/stack"
import { stringsCount, visibleFrets } from "../../guitar/config"
import { ComponentWithChildrenProps } from "@lib/ui/props"
import { Nut } from "./Nut"

const Neck = styled.div`
  height: ${toSizeUnit(fretboardConfig.height)};
  position: relative;

  ${hStack()};
`

const OpenNotes = styled.div`
  width: ${toSizeUnit(fretboardConfig.openNotesSectionWidth)};
`

const Frets = styled.div`
  position: relative;
  flex: 1;
  background: ${getColor("foreground")};
`

export const Fretboard = ({ children }: ComponentWithChildrenProps) => {
  return (
    <Neck>
      <OpenNotes />
      <Nut />
      <Frets>
        {range(visibleFrets).map((index) => (
          <Fret key={index} index={index} />
        ))}
        {getFretMarkers(visibleFrets).map((value) => (
          <FretMarkerItem key={value.index} value={value} />
        ))}

        {range(stringsCount).map((index) => (
          <String key={index} index={index} />
        ))}
        {children}
      </Frets>
    </Neck>
  )
}

Positioning the Neck and Frets

Other static parameters, like the neck height, are stored in a single source of truth within the config.ts file.

const noteSize = 36
const noteFretOffset = 2

export const fretboardConfig = {
  height: 240,
  nutWidth: 20,
  stringsOffset: 0.04,
  noteSize,
  openNotesSectionWidth: noteSize + noteFretOffset * 2,
  noteFretOffset,
  thickestStringWidth: 8,
}

Representing Fret Markers

The first child of the Neck component is the OpenNotes component, which acts as a placeholder for the open notes. It has a fixed width equal to the size of a note plus the offset.

Next is the Nut component, which also has a fixed width but includes a background color to visually highlight the beginning of the fretboard.

import { toSizeUnit } from "@lib/ui/css/toSizeUnit"
import { getColor } from "@lib/ui/theme/getters"
import styled from "styled-components"
import { fretboardConfig } from "./config"

export const Nut = styled.div`
  height: ${toSizeUnit(fretboardConfig.height)};
  width: ${toSizeUnit(fretboardConfig.nutWidth)};
  background: ${getColor("textShy")};
`

The Frets component occupies the remaining space, features a background color to contrast with the page background, and uses a relative position to enable absolute positioning of its child components.

Next, we iterate over the visible frets and render a Fret component for each one. Variables like the number of visible frets and string count are stored in a separate configuration file. This file also includes other essential parameters, such as the total number of frets, the tuning of the strings, and the thickness of each string.

export const stringsCount = 6
export const visibleFrets = 15
export const totalFrets = 22

export const tuning = [7, 2, 10, 5, 0, 7]

export const stringsThickness = [0.1, 0.15, 0.25, 0.4, 0.7, 1]

To represent a fret, we simply render a line with a width of 1px. To position an element by its center on the horizontal axis, we use the PositionAbsolutelyCenterVertically component from RadzionKit.

import { ComponentWithIndexProps } from "@lib/ui/props"
import { PositionAbsolutelyCenterVertically } from "@lib/ui/layout/PositionAbsolutelyCenterVertically"
import styled from "styled-components"
import { getColor } from "@lib/ui/theme/getters"
import { toPercents } from "@lib/utils/toPercents"
import { getFretPosition } from "@product/core/guitar/getFretPosition"
import { totalFrets, visibleFrets } from "../../guitar/config"

const Container = styled.div`
  background: ${getColor("textShy")};
  height: 100%;
  width: 1px;
`

export const Fret = ({ index }: ComponentWithIndexProps) => {
  return (
    <PositionAbsolutelyCenterVertically
      fullHeight
      left={toPercents(
        getFretPosition({
          index,
          visibleFrets,
          totalFrets,
        }).end,
      )}
    >
      <Container key={index} />
    </PositionAbsolutelyCenterVertically>
  )
}

To calculate the left position, we use the getFretPosition utility function. This function returns the start and end positions of a fret based on its index and the total number of frets. To make the fretboard look more realistic, we ensure the frets get progressively closer together as they move up the neck.

import { Interval } from "@lib/utils/interval/Interval"

type Input = {
  index: number
  visibleFrets: number
  totalFrets: number
}

export const getFretPosition = ({
  index,
  visibleFrets,
  totalFrets,
}: Input): Interval => {
  function fretPosition(n: number): number {
    return 1 - 1 / Math.pow(2, n / 12)
  }

  const totalFretboardLength = fretPosition(totalFrets)

  const startFretPos = fretPosition(0)
  const endFretPos = fretPosition(visibleFrets)

  const normalizedStartPos = startFretPos / totalFretboardLength
  const normalizedEndPos = endFretPos / totalFretboardLength

  const fretStartPos = fretPosition(index)
  const normalizedFretStartPos = fretStartPos / totalFretboardLength

  const normalizedStartPosition =
    (normalizedFretStartPos - normalizedStartPos) /
    (normalizedEndPos - normalizedStartPos)

  const fretEndPos = fretPosition(index + 1)
  const normalizedFretEndPos = fretEndPos / totalFretboardLength

  const normalizedEndPosition =
    (normalizedFretEndPos - normalizedStartPos) /
    (normalizedEndPos - normalizedStartPos)

  return {
    start: normalizedStartPosition,
    end: normalizedEndPosition,
  }
}

To enhance realism, we also display fret markers. We use the PositionAbsolutelyCenterVertically component to position the markers on the fretboard and getFretPosition to determine the start and end positions of each fret. Since the fret position is represented as a generic Interval type, we can easily calculate the center of the interval using the getIntervalCenter utility function.

import { ComponentWithValueProps } from "@lib/ui/props"
import { FretMarker } from "@product/core/guitar/fretMarkers"
import { round } from "@lib/ui/css/round"
import { sameDimensions } from "@lib/ui/css/sameDimensions"
import { getColor } from "@lib/ui/theme/getters"
import styled from "styled-components"
import { fretboardConfig } from "./config"
import { PositionAbsolutelyCenterVertically } from "@lib/ui/layout/PositionAbsolutelyCenterVertically"
import { toPercents } from "@lib/utils/toPercents"
import { Match } from "@lib/ui/base/Match"
import { Center } from "@lib/ui/layout/Center"
import { vStack } from "@lib/ui/css/stack"
import { verticalPadding } from "@lib/ui/css/verticalPadding"
import { getIntervalCenter } from "@lib/utils/interval/getIntervalCenter"
import { getFretPosition } from "@product/core/guitar/getFretPosition"
import { visibleFrets, totalFrets } from "../../guitar/config"

const Dot = styled.div`
  ${round};
  ${sameDimensions(fretboardConfig.height * 0.12)};
  background: ${getColor("textShy")};
`

const DoubleMarkerContainer = styled.div`
  ${vStack({
    justifyContent: "space-between",
    fullHeight: true,
  })}
  ${verticalPadding(fretboardConfig.height * 0.08)};
`

export const FretMarkerItem = ({
  value,
}: ComponentWithValueProps<FretMarker>) => {
  return (
    <PositionAbsolutelyCenterVertically
      fullHeight
      left={toPercents(
        getIntervalCenter(
          getFretPosition({
            index: value.index,
            visibleFrets,
            totalFrets,
          }),
        ),
      )}
    >
      <Match
        value={value.type}
        single={() => (
          <Center>
            <Dot />
          </Center>
        )}
        double={() => (
          <DoubleMarkerContainer>
            <Dot />
            <Dot />
          </DoubleMarkerContainer>
        )}
      />
    </PositionAbsolutelyCenterVertically>
  )
}

Depending on a marker's type, we render either a single dot or double dots. To handle this, we use the Match component from RadzionKit, which allows us to conditionally render different components based on the union type of the value prop. This approach serves as an excellent alternative to traditional switch statements.

To decide which frets should display markers, we use the getFretMarkers utility function. This function returns an array of FretMarker objects, each containing the fret index and the type of marker to render.

import { range } from "@lib/utils/array/range"
import { chromaticNotesNumber } from "../note"

export const fretMarkerTypes = ["single", "double"] as const
export type FretMarkerType = (typeof fretMarkerTypes)[number]

export type FretMarker = {
  index: number
  type: FretMarkerType
}

export const getFretMarkers = (numberOfFrets: number): FretMarker[] => {
  const markers: FretMarker[] = []

  range(numberOfFrets).forEach((index) => {
    const fretNumber = (index + 1) % chromaticNotesNumber

    if ([3, 5, 7, 9, 12].includes(fretNumber)) {
      markers.push({ index, type: "single" })
    } else if (fretNumber === 0) {
      markers.push({ index, type: "double" })
    }
  })

  return markers
}

Rendering Guitar Strings and Notes

Finally, we render the guitar strings. For vertical positioning, we use the PositionAbsolutelyCenterHorizontally component. To enhance realism, we use a repeating-linear-gradient to create a pattern that simulates the texture of real guitar strings.

import { ComponentWithIndexProps } from "@lib/ui/props"
import { getColor } from "@lib/ui/theme/getters"
import styled, { css } from "styled-components"
import { PositionAbsolutelyCenterHorizontally } from "@lib/ui/layout/PositionAbsolutelyCenterHorizontally"
import { toPercents } from "@lib/utils/toPercents"
import { getStringPosition } from "./utils/getStringPosition"
import { toSizeUnit } from "@lib/ui/css/toSizeUnit"
import { fretboardConfig } from "./config"
import { stringsThickness } from "../../guitar/config"

const Container = styled.div<{ isBassString: boolean }>`
  background: ${({ isBassString }) =>
    isBassString
      ? css`repeating-linear-gradient(135deg, ${getColor("background")}, ${getColor("background")} 1.5px, ${getColor("textSupporting")} 1.5px, ${getColor("textSupporting")} 3px)`
      : css`
          ${getColor("textSupporting")}
        `};
  width: calc(100% + ${toSizeUnit(fretboardConfig.nutWidth)});
  margin-left: ${toSizeUnit(-fretboardConfig.nutWidth)};
  position: relative;
  color: ${getColor("background")};
`

export const String = ({ index }: ComponentWithIndexProps) => {
  const isBassString = index > 2

  return (
    <PositionAbsolutelyCenterHorizontally
      top={toPercents(getStringPosition(index))}
      fullWidth
    >
      <Container
        isBassString={isBassString}
        style={{
          height: fretboardConfig.thickestStringWidth * stringsThickness[index],
        }}
        key={index}
      />
    </PositionAbsolutelyCenterHorizontally>
  )
}

Implementing the Scale Page

With the Fretboard component complete, we return to the NotesPageContent component to display the actual notes. Here, we iterate over each string and visible fret. Starting with the note of the open string, we add the fret index to calculate the note's position on the chromatic scale. We also determine whether the note is natural or sharp/flat. By setting the kind property to secondary, we make sharp/flat notes less visually prominent.

Managing Scale State and URL Updates

We represent each note with a number: A is 0, A# is 1, and so on. To generate names for the notes, we use a minor scale pattern and iterate over it. For each step in the pattern, if the step equals two, it indicates a sharp note between the two natural notes, so we include it in the array. To determine if a note is natural, we check if its name has a length of 1.

import { scalePatterns } from "../scale"

export const naturalNotesNames = ["A", "B", "C", "D", "E", "F", "G"]

export const chromaticNotesNames = scalePatterns.minor.reduce(
  (acc, step, index) => {
    const note = naturalNotesNames[index]

    acc.push(note)

    if (step === 2) {
      acc.push(`${note}#`)
    }

    return acc
  },
  [] as string[],
)

export const chromaticNotesNumber = chromaticNotesNames.length

export const isNaturalNote = (note: number) =>
  chromaticNotesNames[note].length === 1

Our Note component supports three kinds: regular, secondary, and primary. The primary kind is used to highlight the root note of the scale. To position a note, we pass the string index and fret index. If the fret is null, it indicates an open string.

import { toPercents } from "@lib/utils/toPercents"
import { getStringPosition } from "./utils/getStringPosition"
import { getFretPosition } from "@product/core/guitar/getFretPosition"
import { toSizeUnit } from "@lib/ui/css/toSizeUnit"
import { fretboardConfig } from "./config"
import styled, { css, useTheme } from "styled-components"
import { round } from "@lib/ui/css/round"
import { sameDimensions } from "@lib/ui/css/sameDimensions"
import { PositionAbsolutelyByCenter } from "@lib/ui/layout/PositionAbsolutelyByCenter"
import { getColor } from "@lib/ui/theme/getters"
import {
  ComponentWithKindProps,
  ComponentWithValueProps,
  StyledComponentWithColorProps,
} from "@lib/ui/props"
import { centerContent } from "@lib/ui/css/centerContent"
import { chromaticNotesNames } from "@product/core/note"
import { totalFrets, visibleFrets } from "../../guitar/config"
import { match } from "@lib/utils/match"

type NoteKind = "regular" | "secondary" | "primary"

type NoteProps = Partial<ComponentWithKindProps<NoteKind>> &
  ComponentWithValueProps<number> & {
    string: number
    fret: number | null
  }

const Container = styled.div<
  ComponentWithKindProps<NoteKind> & StyledComponentWithColorProps
>`
  ${round}
  ${sameDimensions(fretboardConfig.noteSize)}

  border: 1px solid transparent;
  ${centerContent};

  ${({ kind, $color, theme: { colors } }) =>
    match(kind, {
      regular: () => css`
        border-color: ${$color.toCssValue()};
        background: ${getColor("background")};
        color: ${getColor("contrast")};
      `,
      secondary: () => css`
        background: ${getColor("foreground")};
        border-color: ${getColor("mistExtra")};
        color: ${getColor("textSupporting")};
      `,
      primary: () => css`
        background: ${$color.toCssValue()};
        color: ${$color
          .getHighestContrast(colors.background, colors.text)
          .toCssValue()};
        font-weight: 600;
      `,
    })}
`

export const Note = ({ string, fret, kind = "regular", value }: NoteProps) => {
  const top = toPercents(getStringPosition(string))

  const {
    colors: { getLabelColor },
  } = useTheme()

  const left = `calc(${
    fret === null
      ? toSizeUnit(-fretboardConfig.nutWidth)
      : toPercents(
          getFretPosition({ totalFrets, visibleFrets, index: fret }).end,
        )
  } - ${toSizeUnit(fretboardConfig.noteSize / 2 + fretboardConfig.noteFretOffset)})`

  return (
    <PositionAbsolutelyByCenter top={top} left={left}>
      <Container $color={getLabelColor(value)} kind={kind}>
        {chromaticNotesNames[value]}
      </Container>
    </PositionAbsolutelyByCenter>
  )
}

As with other fretboard elements, we rely heavily on constants from the config files to calculate the note's position. The PositionAbsolutelyByCenter component helps us position the note precisely by its center.

With all the notes displayed on the fretboard, we can now move on to the scale page. This page uses a dynamic route with three parts: the scale type, the root note, and the scale name. The scale type can be either scale or pentatonic, the root note is one of the 12 notes, and the scale name corresponds to one of the predefined scales.

import { ScalePattern } from "./ScalePattern"

export const scales = [
  "major",
  "minor",
  "blues",
  "dorian",
  "mixolydian",
  "phrygian",
  "harmonic-minor",
  "melodic-minor",
] as const

export type Scale = (typeof scales)[number]

export const scalePatterns: Record<Scale, ScalePattern> = {
  major: [2, 2, 1, 2, 2, 2, 1],
  minor: [2, 1, 2, 2, 1, 2, 2],
  blues: [3, 2, 1, 1, 3, 2],
  dorian: [2, 1, 2, 2, 2, 1, 2],
  mixolydian: [2, 2, 1, 2, 2, 1, 2],
  phrygian: [1, 2, 2, 2, 1, 2, 2],
  ["harmonic-minor"]: [2, 1, 2, 2, 1, 3, 1],
  ["melodic-minor"]: [2, 1, 2, 2, 2, 2, 1],
}

export const scaleNames: Record<Scale, string> = {
  major: "Major",
  minor: "Minor",
  blues: "Blues",
  dorian: "Dorian",
  mixolydian: "Mixolydian",
  phrygian: "Phrygian",
  ["harmonic-minor"]: "Harmonic Minor",
  ["melodic-minor"]: "Melodic Minor",
}

export const pentatonicPatterns: Record<Scale, ScalePattern> = {
  major: [2, 2, 3, 2, 3],
  minor: [3, 2, 2, 3, 2],
  blues: [3, 2, 1, 3, 2],
  dorian: [2, 3, 2, 2, 3],
  mixolydian: [2, 2, 3, 2, 3],
  phrygian: [1, 3, 2, 3, 2],
  ["harmonic-minor"]: [2, 1, 3, 2, 3],
  ["melodic-minor"]: [2, 3, 2, 2, 3],
}

export const scaleTypes = ["scale", "pentatonic"] as const
export type ScaleType = (typeof scaleTypes)[number]

export const pentatonicNotesNumber = 5

We represent a scale as an array of steps, where each step indicates the number of semitones between two notes. Most scales, except the blues scale, have seven steps, while pentatonic scales have five steps.

To manage the selected configuration, we use a ScaleState type. This state is stored in a React context. Using the getValueProviderSetup utility from RadzionKit, we create both a provider and a hook for accessing the state seamlessly.

import { getValueProviderSetup } from "@lib/ui/state/getValueProviderSetup"
import { Scale, ScaleType } from "@product/core/scale"
import { useRouter } from "next/router"
import { useCallback } from "react"
import { toUriNote } from "@product/core/note/uriNote"

export type ScaleState = {
  scale: Scale
  scaleType: ScaleType
  rootNote: number
}

export const makeScalePath = ({ scaleType, scale, rootNote }: ScaleState) =>
  `/${scaleType}/${toUriNote(rootNote)}/${scale}`

export const { useValue: useScale, provider: ScaleProvider } =
  getValueProviderSetup<ScaleState>("Scale")

export const useChangeScale = () => {
  const value = useScale()

  const { push } = useRouter()

  return useCallback(
    (params: Partial<ScaleState>) => {
      push(makeScalePath({ ...value, ...params }))
    },
    [push, value],
  )
}

Generating Static Pages for Better SEO

When the user changes the scale type, root note, or scale, we need to redirect them to the new URL. For this, we use the useChangeScale hook. This hook retrieves the current scale state and the router instance. It returns a callback that updates the URL based on the new scale state. To construct the URL, we use the makeScalePath utility function.

To make the scale page URL more readable, we convert the numeric note into a URI-friendly format. The sharp symbol is replaced with -sharp, and the note is converted to lowercase. To reverse this process and convert the URI note back to a numeric note, we use the fromUriNote utility function.

import { chromaticNotesNames, chromaticNotesNumber } from "."

export const toUriNote = (note: number) =>
  chromaticNotesNames[note % chromaticNotesNumber]
    .replace("#", "-sharp")
    .toLowerCase()

export const fromUriNote = (uriNote: string) => {
  const noteName = uriNote.replace("-sharp", "#").toUpperCase()

  return chromaticNotesNames.findIndex((n) => n === noteName)
}

Since each scale has its own static page, we need to generate them. To achieve this, we use the getStaticPaths and getStaticProps functions from Next.js. The getStaticPaths function generates all possible paths by combining the scale types, root notes, and scales. The getStaticProps function extracts the scale type, root note, and scale from the URL and provides them as props to the page.

import { GetStaticPaths, GetStaticProps } from "next"
import { Scale, scales, ScaleType, scaleTypes } from "@product/core/scale"
import { chromaticNotesNumber } from "@product/core/note"
import { toUriNote, fromUriNote } from "@product/core/note/uriNote"
import { ScalePage } from "../../../../scale/ScalePage"
import { range } from "@lib/utils/array/range"

export default ScalePage

type Params = {
  scaleType: string
  rootNote: string
  scale: string
}

export const getStaticPaths: GetStaticPaths<Params> = async () => {
  const paths = scaleTypes.flatMap((scaleType) =>
    scales.flatMap((scale) =>
      range(chromaticNotesNumber).flatMap((rootNote) => ({
        params: {
          scaleType,
          rootNote: toUriNote(rootNote),
          scale,
        },
      })),
    ),
  )

  return {
    paths,
    fallback: false,
  }
}

export const getStaticProps: GetStaticProps = async ({ params }) => {
  const { scaleType, rootNote, scale } = params as Params
  const rootNoteNumber = fromUriNote(rootNote)

  return {
    props: {
      value: {
        scaleType: scaleType as ScaleType,
        rootNote: rootNoteNumber,
        scale: scale as Scale,
      },
    },
  }
}

Working with Scale Patterns

The ScalePage component receives the scale state as props and passes it to the ScaleProvider. This allows child components to easily access the scale state without the need for prop drilling.

import { VStack } from "@lib/ui/css/stack"
import { ScalePageTitle } from "./ScalePageTitle"
import { ScaleProvider, ScaleState } from "./state/scale"
import { ComponentWithValueProps } from "@lib/ui/props"
import { ScaleNotes } from "./ScaleNotes"
import { PentatonicPatterns } from "./patterns/PentatonicPatterns"
import { PageContainer } from "../layout/PageContainer"
import { ScaleManager } from "./manage/ScaleManager"

export const ScalePage = ({ value }: ComponentWithValueProps<ScaleState>) => {
  return (
    <ScaleProvider value={value}>
      <PageContainer>
        <VStack gap={120}>
          <VStack gap={60}>
            <ScaleManager />
            <ScalePageTitle />
            <ScaleNotes />
          </VStack>
          {value.scaleType === "pentatonic" && <PentatonicPatterns />}
        </VStack>
      </PageContainer>
    </ScaleProvider>
  )
}

At the top of the page, we display controls that let the user select the scale they want to view. These controls include the root note, scale, and scale type, presented in that order. The ScaleManager component organizes these controls within a flexbox row.

import { HStack } from "@lib/ui/css/stack"
import { ManageRootNote } from "./ManageRootNote"
import { ManageScale } from "./ManageScale"
import { ManageScaleType } from "./ManageScaleType"

export const ScaleManager = () => {
  return (
    <HStack alignItems="center" gap={16} fullWidth justifyContent="center">
      <ManageRootNote />
      <ManageScale />
      <ManageScaleType />
    </HStack>
  )
}

To display the root note and scale selectors, we use the ExpandableSelector component from RadzionKit. You can learn more about its implementation here.

Scale selector
Scale selector

import { range } from "@lib/utils/array/range"
import { ExpandableSelector } from "@lib/ui/select/ExpandableSelector"
import { chromaticNotesNames, chromaticNotesNumber } from "@product/core/note"
import { useChangeScale, useScale } from "../state/scale"

export const ManageRootNote = () => {
  const { rootNote } = useScale()
  const setValue = useChangeScale()

  return (
    <ExpandableSelector
      value={rootNote}
      onChange={(rootNote) => {
        setValue({ rootNote })
      }}
      options={range(chromaticNotesNumber)}
      getOptionKey={(index) => chromaticNotesNames[index]}
      ariaLabel="Root note"
    />
  )
}

To toggle between the scale and pentatonic views, we use the GroupedRadioInput component from RadzionKit.

import { capitalizeFirstLetter } from "@lib/utils/capitalizeFirstLetter"
import { GroupedRadioInput } from "@lib/ui/inputs/GroupedRadioInput"
import { scaleTypes } from "@product/core/scale"
import { useChangeScale, useScale } from "../state/scale"

export const ManageScaleType = () => {
  const { scaleType } = useScale()
  const setValue = useChangeScale()

  return (
    <GroupedRadioInput
      options={scaleTypes}
      renderOption={capitalizeFirstLetter}
      value={scaleType}
      onChange={(scaleType) => setValue({ scaleType })}
    />
  )
}

Next, we display the title, which follows the same principles as the home page title. However, on this page, the text is dynamically generated based on the selected scale.

import { Text } from "@lib/ui/text"
import { useScale } from "./state/scale"
import { chromaticNotesNames } from "@product/core/note"
import { scaleNames } from "@product/core/scale"
import { capitalizeFirstLetter } from "@lib/utils/capitalizeFirstLetter"
import { PageMetaTags } from "@lib/next-ui/metadata/PageMetaTags"

export const ScalePageTitle = () => {
  const { scale, rootNote, scaleType } = useScale()

  const noteName = chromaticNotesNames[rootNote]
  const scaleName = scaleNames[scale]
  const scaleTypeName = capitalizeFirstLetter(scaleType)

  const title = `${noteName} ${scaleName} ${scaleTypeName} on Guitar`
  const description = `Learn how to play the ${noteName} ${scaleName} ${scaleTypeName} on the guitar. Explore notes on the fretboard and discover pentatonic and full scale patterns.`

  return (
    <>
      <Text centerHorizontally weight={800} size={32} color="contrast" as="h1">
        {title}
      </Text>
      <PageMetaTags title={title} description={description} />
    </>
  )
}

Based on the selected scale type, we retrieve the appropriate pattern from either the scalePatterns or pentatonicPatterns records. We then use the getScaleNotes utility function to determine the notes of the scale.

import { getLastItem } from "@lib/utils/array/getLastItem"
import { chromaticNotesNumber } from "../note"
import { ScalePattern } from "./ScalePattern"

type Input = {
  rootNote: number
  pattern: ScalePattern
}

export const getScaleNotes = ({ rootNote, pattern }: Input): number[] =>
  pattern.reduce(
    (notes, step) => [
      ...notes,
      (getLastItem(notes) + step) % chromaticNotesNumber,
    ],
    [rootNote],
  )

Next, we iterate over the strings and frets, rendering a note only if it belongs to the scale. If the note is a root note, we pass the primary kind to highlight it.

import { range } from "@lib/utils/array/range"
import { chromaticNotesNumber } from "@product/core/note"
import { Fretboard } from "../guitar/fretboard/Fretboard"
import { stringsCount, tuning, visibleFrets } from "../guitar/config"
import { Note } from "../guitar/fretboard/Note"
import { scalePatterns, pentatonicPatterns } from "@product/core/scale"
import { getScaleNotes } from "@product/core/scale/getScaleNotes"
import { useScale } from "./state/scale"

export const ScaleNotes = () => {
  const { scale, rootNote, scaleType } = useScale()

  const pattern = (
    scaleType === "pentatonic" ? pentatonicPatterns : scalePatterns
  )[scale]

  const notes = getScaleNotes({
    pattern,
    rootNote,
  })

  return (
    <Fretboard>
      {range(stringsCount).map((string) => {
        const openNote = tuning[string]
        return range(visibleFrets + 1).map((index) => {
          const note = (openNote + index) % chromaticNotesNumber
          const fret = index === 0 ? null : index - 1

          if (notes.includes(note)) {
            return (
              <Note
                key={`${string}-${index}`}
                string={string}
                fret={fret}
                value={note}
                kind={rootNote === note ? "primary" : "regular"}
              />
            )
          }

          return null
        })
      })}
    </Fretboard>
  )
}

Displaying Pentatonic Patterns

When displaying the pentatonic scale, we also render the five pentatonic patterns, as it's a common practice to learn them individually.

import { range } from "@lib/utils/array/range"
import { PentatonicPattern } from "./PentatonicPattern"
import { pentatonicNotesNumber, scaleNames } from "@product/core/scale"
import { Text } from "@lib/ui/text"
import { chromaticNotesNames } from "@product/core/note"
import { useScale } from "../state/scale"
import { VStack } from "@lib/ui/css/stack"

export const PentatonicPatterns = () => {
  const { rootNote, scale } = useScale()

  const noteName = chromaticNotesNames[rootNote]
  const scaleName = scaleNames[scale]

  const title = `${noteName} ${scaleName} Pentatonic Patterns`

  return (
    <VStack gap={60}>
      <Text centerHorizontally weight={800} size={32} color="contrast" as="h2">
        {title}
      </Text>
      {range(pentatonicNotesNumber).map((index) => (
        <PentatonicPattern key={index} index={index} />
      ))}
    </VStack>
  )
}

We create a separate section on the page for this, complete with an <h2> title. Within the section, we iterate over the five patterns using the PentatonicPattern component.

import { ComponentWithIndexProps } from "@lib/ui/props"
import { useScale } from "../state/scale"
import { pentatonicPatterns } from "@product/core/scale"
import { getScaleNotes } from "@product/core/scale/getScaleNotes"
import { range } from "@lib/utils/array/range"
import { chromaticNotesNumber } from "@product/core/note"
import { stringsCount, tuning, visibleFrets } from "../../guitar/config"
import { Fretboard } from "../../guitar/fretboard/Fretboard"
import { Note } from "../../guitar/fretboard/Note"
import { withoutUndefined } from "@lib/utils/array/withoutUndefined"
import { VStack } from "@lib/ui/css/stack"
import { Text } from "@lib/ui/text"

export const PentatonicPattern = ({ index }: ComponentWithIndexProps) => {
  const { scale, rootNote } = useScale()

  const pattern = pentatonicPatterns[scale]

  const notes = getScaleNotes({
    pattern,
    rootNote,
  })

  const title = `Pentatonic Pattern #${index + 1}`

  return (
    <VStack gap={24}>
      <Text centerHorizontally color="contrast" as="h3" weight="600" size={16}>
        {title}
      </Text>
      <Fretboard>
        {range(stringsCount).map((string) => {
          const openNote = tuning[string]

          const stringNotes = withoutUndefined(
            range(visibleFrets + 1).map((index) => {
              const note = (openNote + index) % chromaticNotesNumber
              const fret = index === 0 ? null : index - 1

              if (!notes.includes(note)) return

              return { note, fret }
            }),
          ).slice(index, index + 2)

          return stringNotes.map(({ note, fret }) => {
            return (
              <Note
                key={`${string}-${index}`}
                string={string}
                fret={fret}
                value={note}
                kind={rootNote === note ? "primary" : "regular"}
              />
            )
          })
        })}
      </Fretboard>
    </VStack>
  )
}

The approach is similar to the full scale, but this time we render only two notes per string, shifting the pattern by one note with each iteration. This allows us to display all five patterns on individual fretboards within the section.

Pentatonic patterns
Pentatonic patterns

Conclusion

With this app, guitarists can easily visualize scales, explore patterns, and better understand the fretboard. By combining React, TypeScript, and Next.js, we created a dynamic and SEO-friendly tool that serves as both an educational resource and a practice companion. Happy playing!