├── vite-env.d.ts
├── public
└── logo.png
├── vite.config.ts
├── src
├── components
│ ├── Footer.tsx
│ ├── Title.tsx
│ ├── common
│ │ ├── Button.tsx
│ │ └── AlignIcon.tsx
│ ├── StringTemplate.tsx
│ └── FontSizeSlider.tsx
├── main.tsx
├── CustomToast.tsx
├── utils.ts
├── constants.ts
├── App.tsx
└── index.css
├── tsconfig.node.json
├── .gitignore
├── index.html
├── .eslintrc.cjs
├── tsconfig.json
├── uno.config.ts
├── package.json
└── README.md
/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/afordigital/perfect-line-height/HEAD/public/logo.png
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 | import UnoCSS from 'unocss/vite'
4 |
5 | export default defineConfig({
6 | plugins: [react(), UnoCSS()]
7 | })
8 |
--------------------------------------------------------------------------------
/src/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | export const Footer = () => {
2 | return (
3 |
6 | )
7 | }
8 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "module": "ESNext",
5 | "moduleResolution": "Node",
6 | "allowSyntheticDefaultImports": true
7 | },
8 | "include": [
9 | "vite.config.ts"
10 | ]
11 | }
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom/client'
3 | import App from './App'
4 |
5 | import '@unocss/reset/tailwind-compat.css'
6 | import 'virtual:uno.css'
7 | import './index.css'
8 |
9 | ReactDOM.createRoot(document.getElementById('root')!).render(
10 |
11 |
12 | ,
13 | )
14 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | .env
11 | node_modules
12 | dist
13 | dist-ssr
14 | *.local
15 | pnpm-lock.yaml
16 |
17 | # Editor directories and files
18 | .vscode/*
19 | !.vscode/extensions.json
20 | .idea
21 | .DS_Store
22 | *.suo
23 | *.ntvs*
24 | *.njsproj
25 | *.sln
26 | *.sw?
--------------------------------------------------------------------------------
/src/CustomToast.tsx:
--------------------------------------------------------------------------------
1 | import toast from 'react-hot-toast'
2 |
3 | export function showToast(content: string) {
4 |
5 | toast.success(
6 |
7 | {content}
Is now your clipboard!
8 | ,
9 | {
10 | duration: 3000,
11 | style: { background: 'rgb(67,56,202)' }
12 | }
13 | );
14 | }
--------------------------------------------------------------------------------
/src/components/Title.tsx:
--------------------------------------------------------------------------------
1 | export const Title = () => {
2 | return (
3 | <>
4 |
5 | Perfect Line Height
6 |
7 |
8 | Know the perfect height of your lines based on your font size.
9 |
10 | >
11 | )
12 | }
13 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | export const calculateLineHeight = (size: number) => {
2 | const baseFontSize = 16
3 | const baseLineHeight = 1.5
4 | const minLineHeight = 1
5 | const maxLineHeight = 2
6 |
7 | const lineHeight = baseLineHeight / (size / baseFontSize) - 0.1
8 |
9 | const limitedLineHeight = Math.max(
10 | minLineHeight,
11 | Math.min(lineHeight, maxLineHeight)
12 | )
13 |
14 | return { fontSize: size, lineHeight: limitedLineHeight }
15 | }
16 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Perfect Line Height
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/components/common/Button.tsx:
--------------------------------------------------------------------------------
1 | type Props = {
2 | onClick: () => void
3 | children: string
4 | }
5 |
6 | export const Button = (props: Props) => {
7 | const { onClick, children } = props
8 |
9 | return (
10 |
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/common/AlignIcon.tsx:
--------------------------------------------------------------------------------
1 | import { PropsWithChildren } from 'react'
2 |
3 | type Props = PropsWithChildren & {
4 | active: boolean
5 | onClick: () => void
6 | }
7 |
8 | export const AlignIcon = (props: Props) => {
9 | const { children, active, onClick } = props
10 | return (
11 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: { browser: true, es2020: true },
4 | extends: [
5 | 'eslint:recommended',
6 | 'plugin:react/recommended',
7 | 'plugin:react/jsx-runtime',
8 | 'plugin:react-hooks/recommended',
9 | ],
10 | ignorePatterns: ['dist', '.eslintrc.cjs'],
11 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
12 | settings: { react: { version: '18.2' } },
13 | plugins: ['react-refresh'],
14 | rules: {
15 | 'react-refresh/only-export-components': [
16 | 'warn',
17 | { allowConstantExport: true },
18 | ],
19 | },
20 | }
21 |
--------------------------------------------------------------------------------
/src/constants.ts:
--------------------------------------------------------------------------------
1 | export const MODE = {
2 | LEFT: 'left',
3 | CENTER: 'center',
4 | RIGHT: 'right'
5 | } as const
6 | export type ModeView = (typeof MODE)[keyof typeof MODE]
7 |
8 | export const MODE_VIEW_TO_ALIGNMENT = {
9 | [MODE.LEFT]: 'text-left',
10 | [MODE.CENTER]: 'text-center',
11 | [MODE.RIGHT]: 'text-right'
12 | }
13 |
14 | export const TYPOGRAPHY = {
15 | INTER: 'inter',
16 | ROBOTO: 'roboto',
17 | MONTSERRAT: 'montserrat'
18 | } as const
19 | export type Typography = (typeof TYPOGRAPHY)[keyof typeof TYPOGRAPHY]
20 |
21 | export const TYPOGRAPHY_TO_FONT = {
22 | [TYPOGRAPHY.INTER]: 'font-inter',
23 | [TYPOGRAPHY.ROBOTO]: 'font-roboto',
24 | [TYPOGRAPHY.MONTSERRAT]: 'font-montserrat'
25 | }
26 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": [
6 | "DOM",
7 | "DOM.Iterable",
8 | "ESNext"
9 | ],
10 | "allowJs": false,
11 | "skipLibCheck": true,
12 | "esModuleInterop": false,
13 | "allowSyntheticDefaultImports": true,
14 | "strict": true,
15 | "forceConsistentCasingInFileNames": true,
16 | "module": "ESNext",
17 | "moduleResolution": "Node",
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "noEmit": true,
21 | "jsx": "react-jsx"
22 | },
23 | "include": [
24 | "src"
25 | ],
26 | "references": [
27 | {
28 | "path": "./tsconfig.node.json"
29 | }
30 | ]
31 | }
--------------------------------------------------------------------------------
/uno.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, presetIcons, presetWebFonts, presetUno } from 'unocss'
2 |
3 | export default defineConfig({
4 | theme: {
5 | colors: {
6 | cBackground: '#0D1117',
7 | cPrimary: '#28303F',
8 | cSecondary: '#9EB0C5',
9 | cTextPrimary: '#ABB5C1',
10 | cTextSecondary: '#E5ECFF'
11 | }
12 | },
13 | safelist: [
14 | 'text-right',
15 | 'text-center',
16 | 'text-left',
17 | 'font-inter',
18 | 'font-roboto',
19 | 'font-montserrat'
20 | ],
21 | presets: [
22 | presetUno(),
23 | presetWebFonts({
24 | provider: 'google',
25 | fonts: {
26 | inter: 'Inter',
27 | roboto: 'Roboto',
28 | montserrat: 'Montserrat'
29 | }
30 | }),
31 | presetIcons({
32 | cdn: 'https://esm.sh/',
33 | extraProperties: {
34 | display: 'inline-block',
35 | 'vertical-align': 'middle'
36 | }
37 | })
38 | ]
39 | })
40 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "perfect-line-height",
3 | "version": "0.0.0",
4 | "type": "module",
5 | "scripts": {
6 | "dev": "vite",
7 | "build": "tsc && vite build",
8 | "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
9 | "preview": "vite preview"
10 | },
11 | "dependencies": {
12 | "@radix-ui/react-icons": "^1.3.0",
13 | "@radix-ui/react-select": "^2.0.0",
14 | "@unocss/reset": "^0.56.5",
15 | "js-confetti": "^0.12.0",
16 | "lucide-react": "^0.314.0",
17 | "react": "^18.2.0",
18 | "react-dom": "^18.2.0",
19 | "react-hot-toast": "^2.4.1"
20 | },
21 | "devDependencies": {
22 | "@types/react": "^18.2.25",
23 | "@types/react-dom": "^18.2.10",
24 | "@vitejs/plugin-react": "^4.0.3",
25 | "eslint": "^8.45.0",
26 | "eslint-plugin-react": "^7.32.2",
27 | "eslint-plugin-react-hooks": "^4.6.0",
28 | "eslint-plugin-react-refresh": "^0.4.3",
29 | "typescript": "^5.3.2",
30 | "unocss": "^0.56.5",
31 | "vite": "^4.4.5"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Perfect Line Height
2 |
3 | It is commonly understood that line-height is a 1.5 multiplier of font-size. However, this is not only a reductionist approach, but there are other factors that depend on it such as the font-family or the number of lines. This page is intended as a tool for developers to know the line-height that best suits their text.
4 |
5 | ## Authors
6 |
7 | - [@afor_digital](https://www.github.com/afordigital)
8 |
9 | ## Contributors
10 |
11 |
12 |
13 |
14 |
15 | ## Getting Started
16 |
17 | 1. Clone or fork the repository:
18 |
19 | ```bash
20 | https://github.com/afordigital/perfect-line-height.git
21 | ```
22 |
23 | 2. Install dependencies with your favorite package manager:
24 |
25 | ```bash
26 | # with npm:
27 | npm install
28 |
29 | # with pnpm:
30 | pnpm install
31 |
32 | # with yarn:
33 | yarn install
34 |
35 | # with ultra:
36 | ultra install
37 | ```
38 |
39 | 3. Run in your terminal:
40 |
41 | ```bash
42 | # with npm:
43 | npm run dev
44 |
45 | # with pnpm:
46 | pnpm run dev
47 |
48 | # with yarn:
49 | yarn dev
50 |
51 | # with ultra:
52 | ultra dev
53 | ```
54 |
55 | and open http://localhost:3000 🌺.
56 |
57 | ## Stack
58 |
59 | - Vite > v4.4
60 | - React > v18.2
61 | - UnoCSS > v0.56
62 | - Presets de icons y tipografía en UnoCSS
63 |
64 | ## Deployment
65 |
66 | Vercel: https://perfect-line-height.vercel.app
67 |
--------------------------------------------------------------------------------
/src/components/StringTemplate.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { AlignIcon } from './common/AlignIcon'
3 | import { AlignLeft, AlignJustify, AlignRight } from 'lucide-react'
4 | import {
5 | MODE,
6 | MODE_VIEW_TO_ALIGNMENT,
7 | type ModeView,
8 | type Typography
9 | } from '../constants'
10 |
11 | export const StringTemplate = ({
12 | fontSize,
13 | fontFamily
14 | }: {
15 | fontSize: number
16 | fontFamily: Typography
17 | }) => {
18 | const [modeView, setModeView] = useState(MODE.LEFT)
19 |
20 | const handleView = (view: ModeView) => {
21 | setModeView(view)
22 | }
23 |
24 | return (
25 |
26 |
27 |
handleView(MODE.LEFT)}
30 | >
31 |
32 |
33 |
handleView(MODE.CENTER)}
36 | >
37 |
38 |
39 |
handleView(MODE.RIGHT)}
42 | >
43 |
44 |
45 |
46 |
50 |
51 | Lorem Ipsum is simply the filler text of the printing presses template
52 |
53 |
54 |
55 | )
56 | }
57 |
--------------------------------------------------------------------------------
/src/components/FontSizeSlider.tsx:
--------------------------------------------------------------------------------
1 | import { ChevronDown } from 'lucide-react'
2 | import { TYPOGRAPHY, type Typography } from '../constants'
3 |
4 | type Props = {
5 | fontSize: number
6 | fontFamily: Typography
7 | setFontSize: (fontSize: number) => void
8 | onFontChange: (typography: Typography) => void
9 | }
10 |
11 | export const FontSizeSlider = (props: Props) => {
12 | const { fontSize, fontFamily, setFontSize, onFontChange } = props
13 |
14 | const handleFontSizeChange = (e: React.ChangeEvent) => {
15 | const newSize = parseInt(e.target.value, 10)
16 | setFontSize(newSize)
17 | }
18 |
19 | const optionStyle = 'bg-cPrimary h-10 text-lg text-cTextPrimary font-semibold'
20 |
21 | return (
22 | <>
23 |
24 |
25 |
42 |
43 |
44 |
45 |
46 |
49 |
50 | {fontSize}
51 |
52 |
53 |
54 |
62 |
63 | >
64 | )
65 | }
66 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 |
3 | import JSConfetti from 'js-confetti'
4 | import { Toaster } from 'react-hot-toast'
5 | import { showToast } from './CustomToast'
6 | import { calculateLineHeight } from './utils'
7 | import { Footer } from './components/Footer'
8 | import { StringTemplate } from './components/StringTemplate'
9 | import { Title } from './components/Title'
10 | import { Button } from './components/common/Button'
11 | import { FontSizeSlider } from './components/FontSizeSlider'
12 | import { TYPOGRAPHY, type Typography } from './constants'
13 |
14 | function App() {
15 | const [fontSize, setFontSize] = useState(16)
16 | const [currentString, setCurrentString] = useState>([])
17 | const [fontFamily, setFontFamily] = useState(TYPOGRAPHY.INTER)
18 |
19 | const handleKeyPress = (event: KeyboardEvent) => {
20 | const keyPressed: string = event.key.toLowerCase()
21 | console.log(currentString.join(''))
22 | if (currentString.length >= 8) {
23 | currentString.shift()
24 | }
25 | setCurrentString([...currentString, keyPressed])
26 | }
27 |
28 | const handleChangeTypography = (typography: Typography) => {
29 | setFontFamily(typography)
30 | }
31 |
32 | useEffect(() => {
33 | if (currentString.join('') === 'aforcita') {
34 | const confetti = new JSConfetti()
35 | confetti.addConfetti()
36 | setCurrentString([])
37 | }
38 | document.addEventListener('keypress', handleKeyPress)
39 | return () => {
40 | document.removeEventListener('keypress', handleKeyPress)
41 | }
42 | }, [currentString])
43 |
44 | const generateCode = () => {
45 | const code = `font-size: ${fontSize}px; line-height: ${lineHeight.toFixed(
46 | 2
47 | )};`
48 | navigator.clipboard.writeText(code)
49 | showToast(code)
50 | }
51 |
52 | const { lineHeight } = calculateLineHeight(fontSize)
53 |
54 | return (
55 | <>
56 |
57 |
58 |
59 |
60 |
66 |
67 |
68 |
Line Height
69 |
70 | {lineHeight.toFixed(2)}
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 | >
84 | )
85 | }
86 |
87 | export default App
88 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | background-image: linear-gradient(
3 | rgba(255, 255, 255, 0.06) 1px,
4 | transparent 1px
5 | ),
6 | linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px);
7 | background-size: 20px 20px; /*<- Si cambias estos numeros de aca pudes hacerlos mas pequeños o mas grandes*/
8 | background-color: #3f5270;
9 | }
10 |
11 | .custom-lines {
12 | height: 100%;
13 | background: linear-gradient(
14 | to bottom,
15 | transparent 20%,
16 | #28303f 20%,
17 | #28303f 70%,
18 | transparent 70%
19 | );
20 | background-size: 80% 30px;
21 | }
22 |
23 | .custom-slider {
24 | -webkit-appearance: none;
25 | }
26 |
27 | input[type='range'] {
28 | -webkit-appearance: none;
29 | appearance: none;
30 | background: transparent;
31 | cursor: pointer;
32 | width: 100%;
33 | }
34 |
35 | input[type='range']::-webkit-slider-runnable-track {
36 | background: #28303f;
37 | height: 0.2rem;
38 | }
39 |
40 | input[type='range']::-webkit-slider-thumb {
41 | -webkit-appearance: none; /* Override default look */
42 | appearance: none;
43 | margin-top: -6px; /* Centers thumb on the track */
44 | background-color: #abb5c1;
45 | height: 1rem;
46 | width: 1rem;
47 | border-radius: 50%;
48 | }
49 |
50 | @import '@radix-ui/colors/black-alpha.css';
51 | @import '@radix-ui/colors/mauve.css';
52 | @import '@radix-ui/colors/violet.css';
53 |
54 | /* reset */
55 | button {
56 | all: unset;
57 | }
58 |
59 | .SelectTrigger {
60 | display: inline-flex;
61 | align-items: center;
62 | justify-content: center;
63 | border-radius: 4px;
64 | padding: 0 15px;
65 | font-size: 13px;
66 | line-height: 1;
67 | height: 35px;
68 | gap: 5px;
69 | background-color: white;
70 | color: var(--violet-11);
71 | box-shadow: 0 2px 10px var(--black-a7);
72 | }
73 | .SelectTrigger:hover {
74 | background-color: var(--mauve-3);
75 | }
76 | .SelectTrigger:focus {
77 | box-shadow: 0 0 0 2px black;
78 | }
79 | .SelectTrigger[data-placeholder] {
80 | color: var(--violet-9);
81 | }
82 |
83 | .SelectIcon {
84 | color: Var(--violet-11);
85 | }
86 |
87 | .SelectContent {
88 | overflow: hidden;
89 | background-color: white;
90 | border-radius: 6px;
91 | box-shadow: 0px 10px 38px -10px rgba(22, 23, 24, 0.35),
92 | 0px 10px 20px -15px rgba(22, 23, 24, 0.2);
93 | }
94 |
95 | .SelectViewport {
96 | padding: 5px;
97 | }
98 |
99 | .SelectItem {
100 | font-size: 13px;
101 | line-height: 1;
102 | color: var(--violet-11);
103 | border-radius: 3px;
104 | display: flex;
105 | align-items: center;
106 | height: 25px;
107 | padding: 0 35px 0 25px;
108 | position: relative;
109 | user-select: none;
110 | }
111 | .SelectItem[data-disabled] {
112 | color: var(--mauve-8);
113 | pointer-events: none;
114 | }
115 | .SelectItem[data-highlighted] {
116 | outline: none;
117 | background-color: var(--violet-9);
118 | color: var(--violet-1);
119 | }
120 |
121 | .SelectLabel {
122 | padding: 0 25px;
123 | font-size: 12px;
124 | line-height: 25px;
125 | color: var(--mauve-11);
126 | }
127 |
128 | .SelectSeparator {
129 | height: 1px;
130 | background-color: var(--violet-6);
131 | margin: 5px;
132 | }
133 |
134 | .SelectItemIndicator {
135 | position: absolute;
136 | left: 0;
137 | width: 25px;
138 | display: inline-flex;
139 | align-items: center;
140 | justify-content: center;
141 | }
142 |
143 | .SelectScrollButton {
144 | display: flex;
145 | align-items: center;
146 | justify-content: center;
147 | height: 25px;
148 | background-color: white;
149 | color: var(--violet-11);
150 | cursor: default;
151 | }
152 |
--------------------------------------------------------------------------------