├── 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 | <section className="grid grid-cols-2 flex-1 mt-10 gap-x-24 max-w-5xl mx-auto overflow-hidden w-full"> 59 | <article className="w-full space-y-8"> 60 | <FontSizeSlider 61 | fontSize={fontSize} 62 | fontFamily={fontFamily} 63 | setFontSize={setFontSize} 64 | onFontChange={handleChangeTypography} 65 | /> 66 | 67 | <div className="flex justify-between items-center font-semibold my-4 text-[24px]"> 68 | <p className="text-cTextPrimary">Line Height</p> 69 | <p className="p-2 text-cTextPrimary border-2 border-cPrimary rounded-[4px] flex items-center justify-center w-[64px] h-[40px]"> 70 | {lineHeight.toFixed(2)} 71 | </p> 72 | </div> 73 | 74 | <div className="flex w-full justify-center"> 75 | <Button onClick={generateCode}>Copy CSS</Button> 76 | </div> 77 | </article> 78 | <StringTemplate fontSize={fontSize} fontFamily={fontFamily} /> 79 | </section> 80 | </main> 81 | <Footer /> 82 | <Toaster /> 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 | --------------------------------------------------------------------------------