Heading

A semantic heading element that allows control of typographic attributes.

Features

  • Allows control of size, weight, alignment, and tracking.
  • Clear separation of concerns between semantics and design.

Why

You might be surprised (or overjoyed 😄) to see a <Heading /> component. Allow me to explain.

Why do this:

SomeComponent.tsx
  1. 1
<Heading size="6xl" weight="bold" align="center" tracking="tight">Title</Heading>

Instead of this:

SomeComponent.tsx
  1. 1
<h1 className="text-6xl font-bold text-center tracking-tight">Title</h1>

Because you and your designer need a common language without adding a cognitive burnen on you.

Alignment and weight are pretty much standard at this point, but the quantity and values for the sizes and trackings aren't - that's decided by the designer.

Imagine that you use utility classes throughout your codebase and your designer decides that a certain typography element, the big titles say, should go from 60px to 48px. You have to go through the entire codebase and change all titles from text-6xl to text-5xl.

"Ok, André, but I could just create custom utility classes that use CSS variables and if the designer would need to change all titles to another size, he would just change the CSS variable value".

Yes you could, but that has two problems: you're now forced to learn about and remember to use those custom utility classes. If you forget to use them, and the designer changes the CSS variable it uses, the design will break where you've forgotten. The other problem is that there's no distinction between utility classes that are typographic attributes and one that aren't. It's all mixed together in the className string.

By using the <Heading /> component, you have a clear interface between you and the designer (or you and the design 🤭) available as props, leaving non-typographic attributes to the className="". This way you're not forced to memorize custom tailwind classes, while giving the designer complete control over the typography.

Usage

HeadingExample.tsx
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
<Heading size="6xl">Heading 6xl</Heading>
<Heading size="5xl">Heading 5xl</Heading>
<Heading size="4xl">Heading 4xl</Heading>
<Heading size="3xl">Heading 3xl</Heading>
<Heading size="2xl">Heading 2xl</Heading>
<Heading size="xl">Heading xl</Heading>
<Heading size="lg">Heading lg</Heading>
<Heading size="md">Heading md</Heading>
<Heading size="sm">Heading sm</Heading>
<Heading size="xs">Heading xs</Heading>

Here's the heading component in action.

Heading 6xl

Heading 5xl

Heading 4xl

Heading 3xl

Heading 2xl

Heading xl

Heading lg

Heading md

Heading sm

Heading xs

Props

PropTypeDefault
as'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6''h1'
size'4xl' | '3xl' | '2xl' | 'xl' | 'lg' | 'md' | 'sm' | 'xs''4xl'
weight'thin' | 'extralight' | 'light' | 'normal' | 'medium' | 'semibold' | 'bold' | 'extrabold' | 'black''bold'
align'left' | 'center' | 'right''left'
tracking'tighter', 'tight', 'normal', 'wide', 'wider', 'widest'undefined

The tracking's default value depends on the size prop and is controlled through CSS variables.

tailwind.css
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
/* Default tracking. Can be overridden by the 'tracking' prop on the <Heading /> and <Text /> components) */
--text-size-xs-tracking: 0em;
--text-size-sm-tracking: 0em;
--text-size-md-tracking: 0em;
--text-size-lg-tracking: 0em;
--text-size-xl-tracking: 0em;
--text-size-2xl-tracking: -0.025em;
--text-size-3xl-tracking: -0.025em;
--text-size-4xl-tracking: -0.025em;
--text-size-5xl-tracking: -0.05em;
--text-size-6xl-tracking: -0.05em;

Source

heading.tsx
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
  19. 19
  20. 20
  21. 21
  22. 22
  23. 23
  24. 24
  25. 25
  26. 26
  27. 27
  28. 28
  29. 29
  30. 30
  31. 31
  32. 32
  33. 33
  34. 34
  35. 35
  36. 36
  37. 37
  38. 38
  39. 39
  40. 40
  41. 41
  42. 42
  43. 43
  44. 44
  45. 45
  46. 46
  47. 47
  48. 48
  49. 49
  50. 50
  51. 51
  52. 52
  53. 53
  54. 54
  55. 55
  56. 56
  57. 57
  58. 58
  59. 59
  60. 60
  61. 61
  62. 62
  63. 63
  64. 64
  65. 65
  66. 66
  67. 67
  68. 68
  69. 69
  70. 70
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '~/utils/tailwind-merge.ts'
const headingVariants = cva('', {
variants: {
size: {
'6xl': 'text-size-6xl',
'5xl': 'text-size-5xl',
'4xl': 'text-size-4xl',
'3xl': 'text-size-3xl',
'2xl': 'text-size-2xl',
xl: 'text-size-xl',
lg: 'text-size-lg',
md: 'text-size-md',
sm: 'text-size-sm',
xs: 'text-size-xs',
},
weight: {
black: 'font-black',
extrabold: 'font-extrabold',
bold: 'font-bold',
semibold: 'font-semibold',
medium: 'font-medium',
normal: 'font-normal',
light: 'font-light',
extralight: 'font-extralight',
thin: 'font-thin',
},
align: {
left: 'text-left',
center: 'text-center',
right: 'text-right',
},
tracking: {
tighter: 'tracking-tighter',
tight: 'tracking-tight',
normal: 'tracking-normal',
wide: 'tracking-wide',
wider: 'tracking-wider',
widest: 'tracking-widest',
},
},
})
type HeadingType = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
type HeadingProps = { as?: HeadingType } & VariantProps<typeof headingVariants> & { children: ReactNode } & HTMLAttributes<HTMLHeadingElement>
/**
* A Heading component.
*
* @param {HeadingType} [as='h1'] - The HTML heading element type (h1, h2, h3, h4, h5, or h6).
* @param {string} [weight='bold'] - The font weight of the heading (thin, extralight, light, normal, medium, semibold, bold, extrabold, or black).
* @param {string} [size='4xl'] - The size variant of the heading (4xl, 3xl, 2xl, xl, lg, md, sm, xs).
* @param {string} [align='left'] - The text alignment of the heading (left, center, or right).
* @param {string} [tracking] - The letter spacing of the heading (tighter, tight, normal, wide, wider, or widest).
*/
const Heading = forwardRef<HTMLHeadingElement, HeadingProps>(({ as = 'h1', size = '4xl', weight = 'bold', align = 'left', tracking, children, className, ...props }, ref) => {
const Comp = as
return (
<Comp ref={ref} className={cn(headingVariants({ size, weight, align, tracking }), className)} {...props}>
{children}
</Comp>
)
})
Heading.displayName = 'Heading'
export { Heading }

Styling

The typographic styling is actually pretty simple once you wrap your head around it. For an explanation check out the Fluid Typography Sizing and Scales article.

tailwind.css
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
  19. 19
  20. 20
  21. 21
  22. 22
  23. 23
  24. 24
  25. 25
  26. 26
  27. 27
  28. 28
  29. 29
  30. 30
  31. 31
  32. 32
  33. 33
  34. 34
  35. 35
  36. 36
  37. 37
  38. 38
  39. 39
  40. 40
  41. 41
  42. 42
  43. 43
  44. 44
  45. 45
  46. 46
  47. 47
  48. 48
  49. 49
  50. 50
  51. 51
  52. 52
  53. 53
  54. 54
  55. 55
  56. 56
  57. 57
  58. 58
  59. 59
  60. 60
  61. 61
  62. 62
  63. 63
  64. 64
  65. 65
  66. 66
  67. 67
  68. 68
  69. 69
  70. 70
  71. 71
  72. 72
  73. 73
  74. 74
  75. 75
  76. 76
  77. 77
  78. 78
  79. 79
  80. 80
  81. 81
  82. 82
  83. 83
  84. 84
  85. 85
  86. 86
  87. 87
  88. 88
  89. 89
  90. 90
  91. 91
  92. 92
  93. 93
  94. 94
  95. 95
  96. 96
  97. 97
  98. 98
  99. 99
  100. 100
  101. 101
  102. 102
  103. 103
  104. 104
  105. 105
  106. 106
  107. 107
  108. 108
  109. 109
  110. 110
  111. 111
  112. 112
  113. 113
  114. 114
  115. 115
  116. 116
  117. 117
--container-min-width: 20; /* 320px */
--container-max-width: 96; /* 1536px */
--text-size-deltaX: calc(var(--container-max-width) - var(--container-min-width));
/* 6xl */
--text-size-6xl-min-font-size: 4.5; /* 👈 you can edit this value (in rem) */
--text-size-6xl-max-font-size: 8; /* 👈 you can edit this value (in rem) */
--text-size-6xl-deltaY: calc(var(--text-size-6xl-max-font-size) - var(--text-size-6xl-min-font-size));
--text-size-6xl-gradient: calc(var(--text-size-6xl-deltaY) / var(--text-size-deltaX));
--text-size-6xl-intercept: calc(var(--text-size-6xl-min-font-size) - (var(--text-size-6xl-gradient) * var(--container-min-width)));
--text-size-6xl-font-size: calc(var(--text-size-6xl-gradient) * 100vw + var(--text-size-6xl-intercept) * 1rem);
--text-size-6xl: clamp(calc(var(--text-size-6xl-min-font-size) * 1rem), var(--text-size-6xl-font-size), calc(var(--text-size-6xl-max-font-size) * 1rem));
/* 5xl */
--text-size-5xl-min-font-size: 3; /* 👈 you can edit this value (in rem) */
--text-size-5xl-max-font-size: 6; /* 👈 you can edit this value (in rem) */
--text-size-5xl-deltaY: calc(var(--text-size-5xl-max-font-size) - var(--text-size-5xl-min-font-size));
--text-size-5xl-gradient: calc(var(--text-size-5xl-deltaY) / var(--text-size-deltaX));
--text-size-5xl-intercept: calc(var(--text-size-5xl-min-font-size) - (var(--text-size-5xl-gradient) * var(--container-min-width)));
--text-size-5xl-font-size: calc(var(--text-size-5xl-gradient) * 100vw + var(--text-size-5xl-intercept) * 1rem);
--text-size-5xl: clamp(calc(var(--text-size-5xl-min-font-size) * 1rem), var(--text-size-5xl-font-size), calc(var(--text-size-5xl-max-font-size) * 1rem));
/* 4xl */
--text-size-4xl-min-font-size: 2.25; /* 👈 you can edit this value (in rem) */
--text-size-4xl-max-font-size: 3.75; /* 👈 you can edit this value (in rem) */
--text-size-4xl-deltaY: calc(var(--text-size-4xl-max-font-size) - var(--text-size-4xl-min-font-size));
--text-size-4xl-gradient: calc(var(--text-size-4xl-deltaY) / var(--text-size-deltaX));
--text-size-4xl-intercept: calc(var(--text-size-4xl-min-font-size) - (var(--text-size-4xl-gradient) * var(--container-min-width)));
--text-size-4xl-font-size: calc(var(--text-size-4xl-gradient) * 100vw + var(--text-size-4xl-intercept) * 1rem);
--text-size-4xl: clamp(calc(var(--text-size-4xl-min-font-size) * 1rem), var(--text-size-4xl-font-size), calc(var(--text-size-4xl-max-font-size) * 1rem));
/* 3xl */
--text-size-3xl-min-font-size: 1.875; /* 👈 you can edit this value (in rem) */
--text-size-3xl-max-font-size: 2.25; /* 👈 you can edit this value (in rem) */
--text-size-3xl-deltaY: calc(var(--text-size-3xl-max-font-size) - var(--text-size-3xl-min-font-size));
--text-size-3xl-gradient: calc(var(--text-size-3xl-deltaY) / var(--text-size-deltaX));
--text-size-3xl-intercept: calc(var(--text-size-3xl-min-font-size) - (var(--text-size-3xl-gradient) * var(--container-min-width)));
--text-size-3xl-font-size: calc(var(--text-size-3xl-gradient) * 100vw + var(--text-size-3xl-intercept) * 1rem);
--text-size-3xl: clamp(calc(var(--text-size-3xl-min-font-size) * 1rem), var(--text-size-3xl-font-size), calc(var(--text-size-3xl-max-font-size) * 1rem));
/* 2xl */
--text-size-2xl-min-font-size: 1.5; /* 👈 you can edit this value (in rem) */
--text-size-2xl-max-font-size: 1.875; /* 👈 you can edit this value (in rem) */
--text-size-2xl-deltaY: calc(var(--text-size-2xl-max-font-size) - var(--text-size-2xl-min-font-size));
--text-size-2xl-gradient: calc(var(--text-size-2xl-deltaY) / var(--text-size-deltaX));
--text-size-2xl-intercept: calc(var(--text-size-2xl-min-font-size) - (var(--text-size-2xl-gradient) * var(--container-min-width)));
--text-size-2xl-font-size: calc(var(--text-size-2xl-gradient) * 100vw + var(--text-size-2xl-intercept) * 1rem);
--text-size-2xl: clamp(calc(var(--text-size-2xl-min-font-size) * 1rem), var(--text-size-2xl-font-size), calc(var(--text-size-2xl-max-font-size) * 1rem));
/* xl */
--text-size-xl-min-font-size: 1.25; /* 👈 you can edit this value (in rem) */
--text-size-xl-max-font-size: 1.5; /* 👈 you can edit this value (in rem) */
--text-size-xl-deltaY: calc(var(--text-size-xl-max-font-size) - var(--text-size-xl-min-font-size));
--text-size-xl-gradient: calc(var(--text-size-xl-deltaY) / var(--text-size-deltaX));
--text-size-xl-intercept: calc(var(--text-size-xl-min-font-size) - (var(--text-size-xl-gradient) * var(--container-min-width)));
--text-size-xl-font-size: calc(var(--text-size-xl-gradient) * 100vw + var(--text-size-xl-intercept) * 1rem);
--text-size-xl: clamp(calc(var(--text-size-xl-min-font-size) * 1rem), var(--text-size-xl-font-size), calc(var(--text-size-xl-max-font-size) * 1rem));
/* lg */
--text-size-lg-min-font-size: 1.125; /* 👈 you can edit this value (in rem) */
--text-size-lg-max-font-size: 1.25; /* 👈 you can edit this value (in rem) */
--text-size-lg-deltaY: calc(var(--text-size-lg-max-font-size) - var(--text-size-lg-min-font-size));
--text-size-lg-gradient: calc(var(--text-size-lg-deltaY) / var(--text-size-deltaX));
--text-size-lg-intercept: calc(var(--text-size-lg-min-font-size) - (var(--text-size-lg-gradient) * var(--container-min-width)));
--text-size-lg-font-size: calc(var(--text-size-lg-gradient) * 100vw + var(--text-size-lg-intercept) * 1rem);
--text-size-lg: clamp(calc(var(--text-size-lg-min-font-size) * 1rem), var(--text-size-lg-font-size), calc(var(--text-size-lg-max-font-size) * 1rem));
/* md */
--text-size-md-min-font-size: 1; /* 👈 you can edit this value (in rem) */
--text-size-md-max-font-size: 1.125; /* 👈 you can edit this value (in rem) */
--text-size-md-deltaY: calc(var(--text-size-md-max-font-size) - var(--text-size-md-min-font-size));
--text-size-md-gradient: calc(var(--text-size-md-deltaY) / var(--text-size-deltaX));
--text-size-md-intercept: calc(var(--text-size-md-min-font-size) - (var(--text-size-md-gradient) * var(--container-min-width)));
--text-size-md-font-size: calc(var(--text-size-md-gradient) * 100vw + var(--text-size-md-intercept) * 1rem);
--text-size-md: clamp(calc(var(--text-size-md-min-font-size) * 1rem), var(--text-size-md-font-size), calc(var(--text-size-md-max-font-size) * 1rem));
/* sm */
--text-size-sm-min-font-size: 0.875; /* 👈 you can edit this value (in rem) */
--text-size-sm-max-font-size: 1; /* 👈 you can edit this value (in rem) */
--text-size-sm-deltaY: calc(var(--text-size-sm-max-font-size) - var(--text-size-sm-min-font-size));
--text-size-sm-gradient: calc(var(--text-size-sm-deltaY) / var(--text-size-deltaX));
--text-size-sm-intercept: calc(var(--text-size-sm-min-font-size) - (var(--text-size-sm-gradient) * var(--container-min-width)));
--text-size-sm-font-size: calc(var(--text-size-sm-gradient) * 100vw + var(--text-size-sm-intercept) * 1rem);
--text-size-sm: clamp(calc(var(--text-size-sm-min-font-size) * 1rem), var(--text-size-sm-font-size), calc(var(--text-size-sm-max-font-size) * 1rem));
/* xs */
--text-size-xs-min-font-size: 0.75; /* 👈 you can edit this value (in rem) */
--text-size-xs-max-font-size: 0.875; /* 👈 you can edit this value (in rem) */
--text-size-xs-deltaY: calc(var(--text-size-xs-max-font-size) - var(--text-size-xs-min-font-size));
--text-size-xs-gradient: calc(var(--text-size-xs-deltaY) / var(--text-size-deltaX));
--text-size-xs-intercept: calc(var(--text-size-xs-min-font-size) - (var(--text-size-xs-gradient) * var(--container-min-width)));
--text-size-xs-font-size: calc(var(--text-size-xs-gradient) * 100vw + var(--text-size-xs-intercept) * 1rem);
--text-size-xs: clamp(calc(var(--text-size-xs-min-font-size) * 1rem), var(--text-size-xs-font-size), calc(var(--text-size-xs-max-font-size) * 1rem));
/* Line height (this depends on paragraph length) */
--text-size-xs-line-height: clamp(1.125rem, 6.2vw, 1.75rem); /* 12px <-> 14px => 18px <-> 28px */
--text-size-sm-line-height: clamp(1.25rem, 6.2vw, 2rem); /* 20px <-> 32px */
--text-size-md-line-height: clamp(1.5rem, 6vw, 2.25rem); /* 24px <-> 36px */
--text-size-lg-line-height: clamp(1.75rem, 5.8vw, 2.25rem); /* 28px <-> 36px */
--text-size-xl-line-height: clamp(2rem, 6.5vw, 2.5rem); /* 32px <-> 40px */
--text-size-2xl-line-height: 1.5;
--text-size-3xl-line-height: 1.5;
--text-size-4xl-line-height: 1.2;
--text-size-5xl-line-height: 1.2;
--text-size-6xl-line-height: 1.2;
/* Default tracking. Can be overridden by the 'tracking' prop on the <Heading /> and <Text /> components) */
--text-size-xs-tracking: 0em;
--text-size-sm-tracking: 0em;
--text-size-md-tracking: 0em;
--text-size-lg-tracking: 0em;
--text-size-xl-tracking: 0em;
--text-size-2xl-tracking: -0.025em;
--text-size-3xl-tracking: -0.025em;
--text-size-4xl-tracking: -0.025em;
--text-size-5xl-tracking: -0.05em;
--text-size-6xl-tracking: -0.05em;