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.
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.
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.
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.
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)}
`
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} />
</>
)
}
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.
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>
)
}
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,
}
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
}
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>
)
}
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.
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],
)
}
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,
},
},
}
}
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.
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>
)
}
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.
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!