├── .npmrc ├── postcss.config.json ├── .prettierignore ├── static ├── robots.txt ├── images │ ├── favicon-128.png │ ├── favicon-192.png │ ├── favicon-228.png │ ├── favicon-32.png │ ├── favicon-57.png │ ├── favicon-76.png │ ├── favicon-96.png │ └── thumbnail.png └── fonts │ ├── inter-variable-latin-roman.woff2 │ └── sourcecodepro-variable-latin-roman.woff2 ├── src ├── lib │ ├── styles │ │ ├── main.scss │ │ ├── _breakpoints.scss │ │ ├── _components.scss │ │ ├── _reset.scss │ │ ├── _utils.scss │ │ ├── _mixins.scss │ │ ├── _functions.scss │ │ ├── _global.scss │ │ ├── _variables.scss │ │ └── _fontFace.scss │ ├── data │ │ ├── socials.json │ │ ├── site.json │ │ └── typeScaleRatios.json │ ├── constants.ts │ ├── index.ts │ ├── types.ts │ ├── components │ │ ├── Link.svelte │ │ ├── FormError.svelte │ │ ├── App │ │ │ ├── Form │ │ │ │ ├── index.ts │ │ │ │ ├── GroupIncludeFallbacks.svelte │ │ │ │ ├── GroupRounding.svelte │ │ │ │ ├── GroupNamingConvention.svelte │ │ │ │ ├── GroupUseContainerWidth.svelte │ │ │ │ ├── GroupRems.svelte │ │ │ │ ├── GroupTypeScaleSteps.svelte │ │ │ │ ├── GroupMaximum.svelte │ │ │ │ └── GroupMinimum.svelte │ │ │ ├── App.svelte │ │ │ ├── Output.svelte │ │ │ └── Preview.svelte │ │ ├── Select.types.ts │ │ ├── Checkbox.svelte │ │ ├── CopyToClipboardButton.svelte │ │ ├── HeroBanner.svelte │ │ ├── Button.svelte │ │ ├── RangeInput.svelte │ │ ├── PageFooter.svelte │ │ ├── TypeScalePicker.svelte │ │ ├── Fieldset.svelte │ │ ├── Select.svelte │ │ ├── GoogleFontSelect.svelte │ │ ├── Input.svelte │ │ ├── Label.svelte │ │ ├── Info.svelte │ │ └── GitHubCorner.svelte │ ├── utils.ts │ └── schema │ │ ├── schema.utils.ts │ │ └── schema.ts ├── routes │ ├── +error.svelte │ ├── +page.svelte │ ├── +page.server.ts │ ├── calculate │ │ ├── +page.svelte │ │ └── +page.server.ts │ ├── sitemap.xml │ │ └── +server.ts │ └── +layout.svelte ├── app.d.ts ├── app.html └── index.test.ts ├── .editorconfig ├── .gitignore ├── .eslintignore ├── .prettierrc ├── .github └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── vite.config.ts ├── .gitattributes ├── tsconfig.json ├── .eslintrc.cjs ├── svelte.config.js ├── .stylelintrc.json ├── LICENSE ├── prebuild.js ├── package.json └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /postcss.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["autoprefixer"] 3 | } 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore files for PNPM, NPM and YARN 2 | pnpm-lock.yaml 3 | package-lock.json 4 | yarn.lock 5 | -------------------------------------------------------------------------------- /static/robots.txt: -------------------------------------------------------------------------------- 1 | Sitemap: https://www.fluid-type-scale.com/sitemap.xml 2 | 3 | User-agent: * 4 | Allow: / 5 | Disallow: /calculate 6 | -------------------------------------------------------------------------------- /static/images/favicon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AleksandrHovhannisyan/fluid-type-scale-calculator/HEAD/static/images/favicon-128.png -------------------------------------------------------------------------------- /static/images/favicon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AleksandrHovhannisyan/fluid-type-scale-calculator/HEAD/static/images/favicon-192.png -------------------------------------------------------------------------------- /static/images/favicon-228.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AleksandrHovhannisyan/fluid-type-scale-calculator/HEAD/static/images/favicon-228.png -------------------------------------------------------------------------------- /static/images/favicon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AleksandrHovhannisyan/fluid-type-scale-calculator/HEAD/static/images/favicon-32.png -------------------------------------------------------------------------------- /static/images/favicon-57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AleksandrHovhannisyan/fluid-type-scale-calculator/HEAD/static/images/favicon-57.png -------------------------------------------------------------------------------- /static/images/favicon-76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AleksandrHovhannisyan/fluid-type-scale-calculator/HEAD/static/images/favicon-76.png -------------------------------------------------------------------------------- /static/images/favicon-96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AleksandrHovhannisyan/fluid-type-scale-calculator/HEAD/static/images/favicon-96.png -------------------------------------------------------------------------------- /static/images/thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AleksandrHovhannisyan/fluid-type-scale-calculator/HEAD/static/images/thumbnail.png -------------------------------------------------------------------------------- /src/lib/styles/main.scss: -------------------------------------------------------------------------------- 1 | @import './fontFace'; 2 | @import './reset'; 3 | @import './variables'; 4 | @import './utils'; 5 | @import './global'; 6 | @import './components'; 7 | -------------------------------------------------------------------------------- /static/fonts/inter-variable-latin-roman.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AleksandrHovhannisyan/fluid-type-scale-calculator/HEAD/static/fonts/inter-variable-latin-roman.woff2 -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true -------------------------------------------------------------------------------- /src/lib/styles/_breakpoints.scss: -------------------------------------------------------------------------------- 1 | $breakpoints: ( 2 | mobile-min: 360px, 3 | mobile: 400px, 4 | mobile-lg: 500px, 5 | tablet-sm: 640px, 6 | tablet: 768px, 7 | desktop: 1280px 8 | ); 9 | -------------------------------------------------------------------------------- /static/fonts/sourcecodepro-variable-latin-roman.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AleksandrHovhannisyan/fluid-type-scale-calculator/HEAD/static/fonts/sourcecodepro-variable-latin-roman.woff2 -------------------------------------------------------------------------------- /src/routes/+error.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |

{$page.error?.message}

6 | 7 | 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | vite.config.js.timestamp-* 10 | vite.config.ts.timestamp-* 11 | src/lib/data/fonts.json 12 | .netlify/ -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100, 6 | "plugins": ["prettier-plugin-svelte"], 7 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 8 | } 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggestions for new features. 4 | title: '' 5 | labels: enhancement 6 | --- 7 | 8 | ## Description/user story 9 | 10 | ## Additional context, ideas, or considerations 11 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite'; 2 | import { defineConfig } from 'vitest/config'; 3 | 4 | export default defineConfig({ 5 | plugins: [sveltekit()], 6 | test: { 7 | include: ['src/**/*.{test,spec}.{js,ts}'] 8 | } 9 | }); 10 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | // for information about these interfaces 3 | declare global { 4 | namespace App { 5 | // interface Error {} 6 | // interface Locals {} 7 | // interface PageData {} 8 | // interface PageState {} 9 | // interface Platform {} 10 | } 11 | } 12 | 13 | export {}; 14 | -------------------------------------------------------------------------------- /src/lib/data/socials.json: -------------------------------------------------------------------------------- 1 | { 2 | "linkedin": { 3 | "name": "LinkedIn", 4 | "url": "https://www.linkedin.com/in/aleksandr-hovhannisyan-ba154b120/" 5 | }, 6 | "github": { 7 | "name": "GitHub", 8 | "url": "https://github.com/AleksandrHovhannisyan" 9 | }, 10 | "buyMeACoffee": { 11 | "name": "Buy Me a Coffee", 12 | "url": "https://buymeacoffee.com/hovhadovah" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # https://www.aleksandrhovhannisyan.com/blog/crlf-vs-lf-normalizing-line-endings-in-git/ 2 | 3 | # Let Git's auto-detection algorithm infer if a file is text. If it is, 4 | # enforce LF line endings regardless of OS or git configurations. 5 | * text=auto eol=lf 6 | 7 | # Don't brick binary files by changing their line endings 8 | *.{png,jpg,jpeg,gif,webp,woff,woff2,mov,mp3,mp4} binary 9 | -------------------------------------------------------------------------------- /src/lib/data/site.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Fluid Type Scale Calculator", 3 | "metaTitle": "Fluid Type Scale - Generate responsive font-size variables", 4 | "description": "Generate font size variables for a fluid type scale with CSS clamp. Grab the output CSS and drop it into any design system.", 5 | "keywords": ["fluid type scale", "type scale", "CSS clamp"], 6 | "url": "https://www.fluid-type-scale.com" 7 | } 8 | -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/lib/styles/_components.scss: -------------------------------------------------------------------------------- 1 | input:not([type='range']), 2 | select, 3 | textarea { 4 | --input-padding: 8px; 5 | padding: var(--input-padding); 6 | border-radius: 0; 7 | border: solid 1px var(--color-border); 8 | } 9 | 10 | select, 11 | input:where([type='number'], [type='text']) { 12 | min-height: 46px; 13 | } 14 | 15 | :where(button, label, input, textarea, select, option, a, [tabindex='0']):focus-visible { 16 | outline-style: solid; 17 | outline-width: 4px; 18 | } 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report an issue. 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | ## Description 10 | 11 | ## Steps to Reproduce 12 | 13 | ## Expected Behavior 14 | 15 | ## Screenshots (Optional) 16 | 17 | ## Environment (please complete the following information): 18 | 19 | - OS and version: [e.g. Windows 10, iOS 15] 20 | - Browser and version: [e.g. Chrome 97, Safari 15.1] 21 | 22 | ## Additional context (if applicable) 23 | -------------------------------------------------------------------------------- /src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | /** The root API endpoint for requesting @font-face declarations from Google Fonts. */ 2 | export const GOOGLE_FONTS_BASE_URL = `https://fonts.googleapis.com/css2`; 3 | 4 | /** Regex for a comma-separated list of word identifiers, with optional whitespace around the commas. */ 5 | export const COMMA_SEPARATED_LIST_REGEX = 6 | // eslint-disable-next-line no-useless-escape 7 | /^[\w\-]+(\s*,\s*[\w\-]*)*$/; 8 | 9 | export const CSS_VARIABLE_REGEX = 10 | // eslint-disable-next-line no-useless-escape 11 | /^[a-zA-Z0-9_\-]*$/; 12 | -------------------------------------------------------------------------------- /src/lib/styles/_reset.scss: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | margin: 0; 4 | padding: 0; 5 | min-width: 0; 6 | line-height: calc(2ex + 6px); 7 | } 8 | 9 | *, 10 | *::before, 11 | *::after { 12 | @media (prefers-reduced-motion) { 13 | animation: none !important; 14 | transition: none !important; 15 | } 16 | } 17 | 18 | html, 19 | body { 20 | height: 100%; 21 | } 22 | 23 | button, 24 | label, 25 | input, 26 | textarea, 27 | select, 28 | option { 29 | font: inherit; 30 | } 31 | 32 | img { 33 | max-width: 100%; 34 | height: auto; 35 | } 36 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | // place files you want to import through the `$lib` alias in this folder. 2 | export { default as Input } from './components/Input.svelte'; 3 | export { default as Link } from './components/Link.svelte'; 4 | export { default as PageFooter } from './components/PageFooter.svelte'; 5 | export { default as HeroBanner } from './components/HeroBanner.svelte'; 6 | export { default as Info } from './components/Info.svelte'; 7 | export { default as App } from './components/App/App.svelte'; 8 | export { default as GitHubCorner } from './components/GitHubCorner.svelte'; 9 | -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | export type ClampDeclaration = { 2 | /** The minimum value for CSS `clamp`. */ 3 | min: string; 4 | /** The preferred value for CSS `clamp` */ 5 | preferred: string; 6 | /** The maximum value for CSS `clamp`. */ 7 | max: string; 8 | }; 9 | 10 | export type TypeScaleEntry = ClampDeclaration & { 11 | /** Given a unitless target screen width (interpreted as pixels), returns the computed fluid font size at that width. */ 12 | getFontSizeAtScreenWidth: (width: number) => string; 13 | }; 14 | 15 | export type TypeScale = Map; 16 | -------------------------------------------------------------------------------- /src/routes/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { SCHEMA_DEFAULTS, schema } from '$lib/schema/schema'; 2 | import { superValidate } from 'sveltekit-superforms'; 3 | import { valibot } from 'sveltekit-superforms/adapters'; 4 | 5 | // Pre-render the index route for performance, faster load times, and to save Netlify function invocation bandwidth. 6 | export const prerender = true; 7 | 8 | // All we need to do for this loader is init the form with the defaults 9 | export const load = async () => { 10 | const form = await superValidate(SCHEMA_DEFAULTS, valibot(schema)); 11 | return { form }; 12 | }; 13 | -------------------------------------------------------------------------------- /src/lib/components/Link.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 18 | -------------------------------------------------------------------------------- /src/lib/components/FormError.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | {#if errors} 11 | {errors} 12 | {/if} 13 | 14 | 21 | -------------------------------------------------------------------------------- /src/routes/calculate/+page.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/lib/components/App/Form/index.ts: -------------------------------------------------------------------------------- 1 | export { default as GroupMinimum } from './GroupMinimum.svelte'; 2 | export { default as GroupMaximum } from './GroupMaximum.svelte'; 3 | export { default as GroupTypeScaleSteps } from './GroupTypeScaleSteps.svelte'; 4 | export { default as GroupNamingConvention } from './GroupNamingConvention.svelte'; 5 | export { default as GroupRounding } from './GroupRounding.svelte'; 6 | export { default as GroupUseContainerWidth } from './GroupUseContainerWidth.svelte'; 7 | export { default as GroupIncludeFallbacks } from './GroupIncludeFallbacks.svelte'; 8 | export { default as GroupRems } from './GroupRems.svelte'; 9 | -------------------------------------------------------------------------------- /src/lib/components/Select.types.ts: -------------------------------------------------------------------------------- 1 | import type { HTMLOptionAttributes, HTMLSelectAttributes } from 'svelte/elements'; 2 | 3 | export type SelectOption = Pick & { 4 | /** An optional text label to render. If not specified, defaults to rendering the value. */ 5 | label?: string; 6 | }; 7 | 8 | export type SelectProps = Pick & { 9 | /** The delay (in milliseconds) for the change event. Defaults to `0` (no delay). */ 10 | delay?: number; 11 | /** The options to render. */ 12 | options: SelectOption[]; 13 | onInput?: HTMLSelectAttributes['on:input']; 14 | }; 15 | -------------------------------------------------------------------------------- /src/lib/data/typeScaleRatios.json: -------------------------------------------------------------------------------- 1 | { 2 | "minorSecond": { 3 | "name": "Minor second", 4 | "ratio": 1.067 5 | }, 6 | "majorSecond": { 7 | "name": "Major second", 8 | "ratio": 1.125 9 | }, 10 | "minorThird": { 11 | "name": "Minor third", 12 | "ratio": 1.2 13 | }, 14 | "majorThird": { 15 | "name": "Major third", 16 | "ratio": 1.25 17 | }, 18 | "perfectFourth": { 19 | "name": "Perfect fourth", 20 | "ratio": 1.333 21 | }, 22 | "augmentedFourth": { 23 | "name": "Augmented fourth", 24 | "ratio": 1.414 25 | }, 26 | "perfectFifth": { 27 | "name": "Perfect fifth", 28 | "ratio": 1.5 29 | }, 30 | "goldenRatio": { 31 | "name": "Golden ratio", 32 | "ratio": 1.618 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/lib/components/Checkbox.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/lib/styles/_utils.scss: -------------------------------------------------------------------------------- 1 | @import './functions'; 2 | @import './mixins'; 3 | 4 | // Generic utility classes that don't need to be CSS modules 5 | 6 | .sr-only { 7 | position: absolute; 8 | left: 0; 9 | clip: rect(0, 0, 0, 0); 10 | 11 | &:focus { 12 | clip: auto; 13 | } 14 | } 15 | 16 | .list { 17 | padding-inline-start: 1em; 18 | 19 | li { 20 | margin-top: 0.5lh; 21 | } 22 | } 23 | 24 | .rhythm { 25 | > * + * { 26 | margin-top: 1lh; 27 | } 28 | } 29 | 30 | .switcher { 31 | display: grid; 32 | gap: var(--sp-4xl); 33 | grid-template-columns: minmax(0, 1fr); 34 | grid-auto-flow: row; 35 | 36 | @include desktop { 37 | grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "moduleResolution": "bundler" 13 | } 14 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias 15 | // except $lib which is handled by https://kit.svelte.dev/docs/configuration#files 16 | // 17 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 18 | // from the referenced tsconfig.json - TypeScript does not merge them in 19 | } 20 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type { import("eslint").Linter.Config } */ 2 | module.exports = { 3 | root: true, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:svelte/recommended', 8 | 'prettier' 9 | ], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['@typescript-eslint'], 12 | parserOptions: { 13 | sourceType: 'module', 14 | ecmaVersion: 2020, 15 | extraFileExtensions: ['.svelte'] 16 | }, 17 | env: { 18 | browser: true, 19 | es2017: true, 20 | node: true 21 | }, 22 | overrides: [ 23 | { 24 | files: ['*.svelte'], 25 | parser: 'svelte-eslint-parser', 26 | parserOptions: { 27 | parser: '@typescript-eslint/parser' 28 | } 29 | } 30 | ] 31 | }; 32 | -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-cloudflare'; 2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors 7 | // for more information about preprocessors 8 | preprocess: vitePreprocess(), 9 | 10 | kit: { 11 | version: { 12 | name: process.env.npm_package_version 13 | }, 14 | adapter: adapter({ 15 | routes: { 16 | include: ['/*'], 17 | exclude: [''] 18 | }, 19 | platformProxy: { 20 | // configPath: 'wrangler.toml', 21 | environment: undefined, 22 | experimentalJsonConfig: false, 23 | persist: false 24 | } 25 | }) 26 | } 27 | }; 28 | 29 | export default config; 30 | -------------------------------------------------------------------------------- /src/lib/components/App/Form/GroupIncludeFallbacks.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 26 | -------------------------------------------------------------------------------- /src/lib/styles/_mixins.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:map'; 2 | @use 'sass:math'; 3 | @import './breakpoints'; 4 | 5 | @mixin headings { 6 | :is(h1, h2, h3, h4, h5, h6) { 7 | @content; 8 | } 9 | } 10 | 11 | // Breakpoint mixins 12 | 13 | @mixin breakpoint($name) { 14 | $bp: map.get($breakpoints, $name); 15 | $bp: math.div($bp, 16px) * 1em; 16 | @media screen and (min-width: $bp) { 17 | @content; 18 | } 19 | } 20 | 21 | @mixin mobile { 22 | @include breakpoint('mobile') { 23 | @content; 24 | } 25 | } 26 | @mixin mobile-lg { 27 | @include breakpoint('mobile-lg') { 28 | @content; 29 | } 30 | } 31 | @mixin tablet-sm { 32 | @include breakpoint('tablet-sm') { 33 | @content; 34 | } 35 | } 36 | @mixin tablet { 37 | @include breakpoint('tablet') { 38 | @content; 39 | } 40 | } 41 | @mixin desktop { 42 | @include breakpoint('desktop') { 43 | @content; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/lib/components/App/Form/GroupRounding.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 27 | -------------------------------------------------------------------------------- /src/lib/components/CopyToClipboardButton.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 |
11 | 28 |
29 | 36 | -------------------------------------------------------------------------------- /src/lib/components/App/Form/GroupNamingConvention.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 26 | -------------------------------------------------------------------------------- /src/lib/components/App/Form/GroupUseContainerWidth.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 30 | -------------------------------------------------------------------------------- /src/lib/styles/_functions.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:math'; 2 | @use 'sass:list'; 3 | @use 'sass:map'; 4 | @import './breakpoints'; 5 | 6 | @function to-rems($value) { 7 | $value-rems: math.div($value, 16px) * 1rem; 8 | @return $value-rems; 9 | } 10 | 11 | @function rnd($number, $places: 0) { 12 | $n: 1; 13 | @if $places > 0 { 14 | @for $i from 1 through $places { 15 | $n: $n * 10; 16 | } 17 | } 18 | @return math.div(math.round($number * $n), $n); 19 | } 20 | 21 | @function clamped($min-px, $max-px) { 22 | $min-bp: map.get($breakpoints, 'mobile'); 23 | $max-bp: map.get($breakpoints, 'desktop'); 24 | $slope: math.div($max-px - $min-px, $max-bp - $min-bp); 25 | $slope-vw: rnd($slope * 100, 2); 26 | $intercept-rems: rnd(to-rems($min-px - $slope * $min-bp), 2); 27 | $min-rems: rnd(to-rems($min-px), 2); 28 | $max-rems: rnd(to-rems($max-px), 2); 29 | @return clamp(#{$min-rems}, #{$slope-vw}vw + #{$intercept-rems}, #{$max-rems}); 30 | } 31 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | %sveltekit.head% 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
%sveltekit.body%
17 | 18 | 19 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["stylelint-config-standard-scss"], 3 | "rules": { 4 | "at-rule-no-unknown": null, 5 | "at-rule-empty-line-before": null, 6 | "block-closing-brace-newline-after": ["always", { "ignoreAtRules": ["if", "else"] }], 7 | "block-opening-brace-space-before": "always", 8 | "block-opening-brace-newline-after": "always", 9 | "block-closing-brace-newline-before": "always", 10 | "block-closing-brace-empty-line-before": "never", 11 | "declaration-block-trailing-semicolon": "always", 12 | "declaration-empty-line-before": "never", 13 | "declaration-block-semicolon-newline-after": "always-multi-line", 14 | "font-family-name-quotes": "always-where-required", 15 | "no-eol-whitespace": null, 16 | "indentation": 2, 17 | "max-line-length": [120, { "ignore": "comments" }], 18 | "scss/at-if-closing-brace-newline-after": "always-last-in-chain", 19 | "scss/at-if-closing-brace-space-after": "always-intermediate", 20 | "scss/at-rule-no-unknown": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/lib/components/HeroBanner.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 |
9 |

{title}

10 | {#if description} 11 |

{description}

12 | {/if} 13 |
14 | 15 | 48 | -------------------------------------------------------------------------------- /src/lib/components/Button.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 15 | 16 | 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Aleksandr Hovhannisyan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /prebuild.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs'; 3 | 4 | /* NOTE: Q: Why fetch fonts in a standalone script and not on the server side per request? A: Because: 5 | * 1. The index page is statically generated for performance. 6 | * 2. Fonts don't change frequently enough to warrant fetching per request. 7 | * 3. To avoid DDoS attacks or unexpected billing charges in Google Cloud (even though the Google Fonts API currently has no rate limit). */ 8 | 9 | const DESTINATION_FILE = path.resolve('src/lib/data/fonts.json'); 10 | const API_KEY = process.env.GOOGLE_FONTS_API_KEY; 11 | 12 | if (!API_KEY) { 13 | throw new Error('GOOGLE_FONTS_API_KEY was not set.'); 14 | } 15 | 16 | process.stdout.write('Fetching Google Fonts...'); 17 | 18 | // Don't try-catch so build fails 19 | const response = await ( 20 | await fetch(`https://www.googleapis.com/webfonts/v1/webfonts?sort=alpha&key=${API_KEY}`) 21 | ).json(); 22 | const fonts = response.items.map((font) => font.family); 23 | 24 | process.stdout.write(' \x1b[32mDone ✅\x1b[0m '); 25 | 26 | fs.writeFile(DESTINATION_FILE, JSON.stringify(fonts), (e) => { 27 | if (e) { 28 | console.log(e); 29 | } else { 30 | console.log(`\x1b[32mWrote to ${DESTINATION_FILE}.\x1b[0m\n`); 31 | } 32 | }); 33 | -------------------------------------------------------------------------------- /src/routes/calculate/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { SCHEMA_DEFAULTS, schema, type Schema } from '$lib/schema/schema'; 2 | import type { Actions } from '@sveltejs/kit'; 3 | import { superValidate } from 'sveltekit-superforms'; 4 | import { valibot } from 'sveltekit-superforms/adapters'; 5 | 6 | // The /calculate route is strictly for link sharing and progressively enhanced form submissions. This allows the index route to be pre-rendered for SEO and performant page load. 7 | export const prerender = false; 8 | 9 | // Need this for POST to work 10 | export const actions: Actions = { 11 | default: async () => {} 12 | }; 13 | 14 | // Since this route is SSR (prerender=false), this is essentially getServerSideProps, i.e. it runs on each request to /calculate 15 | export const load = async ({ request, url }) => { 16 | let data: Schema | URLSearchParams | FormData = SCHEMA_DEFAULTS; 17 | // GET request 18 | if (request.method === 'GET') { 19 | // Form data optionally serialized in URL (for link sharing) 20 | if (url.searchParams.size) { 21 | data = url.searchParams; 22 | } 23 | } 24 | // POST request, for no-JS form submissions 25 | else if (request.method === 'POST') { 26 | data = await request.formData(); 27 | } 28 | const form = await superValidate(data, valibot(schema)); 29 | return { form }; 30 | }; 31 | -------------------------------------------------------------------------------- /src/lib/components/RangeInput.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 | 24 | 25 | 37 | -------------------------------------------------------------------------------- /src/lib/styles/_global.scss: -------------------------------------------------------------------------------- 1 | @import './functions'; 2 | 3 | ::selection { 4 | background-color: var(--color-surface-dark); 5 | color: var(--color-surface-light); 6 | text-shadow: none; 7 | } 8 | 9 | body { 10 | font-family: var(--ff-body); 11 | font-weight: var(--fw-body-regular); 12 | font-size: var(--sp-base); 13 | accent-color: var(--color-surface-dark); 14 | -webkit-font-smoothing: antialiased; 15 | } 16 | 17 | h1, 18 | h2, 19 | h3, 20 | h4, 21 | h5, 22 | h6 { 23 | font-weight: var(--fw-body-bold); 24 | margin-block-end: 0.25em; 25 | } 26 | 27 | .size-font-3xl, 28 | h1 { 29 | font-size: var(--sp-3xl); 30 | } 31 | .size-font-2xl, 32 | h2 { 33 | font-size: var(--sp-2xl); 34 | } 35 | .size-font-xl, 36 | h3 { 37 | font-size: var(--sp-xl); 38 | } 39 | .size-font-lg, 40 | h4 { 41 | font-size: var(--sp-lg); 42 | } 43 | .size-font-md, 44 | h5 { 45 | font-size: var(--sp-md); 46 | } 47 | .size-font-base, 48 | h6 { 49 | font-size: var(--sp-base); 50 | } 51 | 52 | code { 53 | line-height: 1.8; 54 | font-family: var(--ff-mono); 55 | font-weight: var(--fw-mono-regular); 56 | font-size: var(--sp-sm); 57 | background-color: var(--color-surface-medium); 58 | padding: 4px; 59 | tab-size: 2; 60 | } 61 | 62 | [aria-invalid='true'] { 63 | border-color: var(--color-danger); 64 | outline-color: var(--color-danger); 65 | } 66 | -------------------------------------------------------------------------------- /src/routes/sitemap.xml/+server.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process'; 2 | import site from '$lib/data/site.json'; 3 | 4 | export const prerender = true; 5 | 6 | const URL_CHANGE_FREQUENCY = 'daily'; 7 | const URL_PRIORITY = 0.7; 8 | const URL_LAST_MODIFICATION_DATE = new Date( 9 | execSync('git log -1 --format=%cI').toString().trim() 10 | ).toISOString(); 11 | 12 | export async function GET() { 13 | return new Response( 14 | ` 15 | 22 | 23 | ${site.url} 24 | ${URL_CHANGE_FREQUENCY} 25 | ${URL_PRIORITY} 26 | ${URL_LAST_MODIFICATION_DATE} 27 | 28 | `.trim(), 29 | { 30 | headers: { 31 | 'Content-Type': 'application/xml' 32 | } 33 | } 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/lib/components/PageFooter.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 | 14 |
    15 | {#each Object.entries(socials) as [_, social]} 16 |
  • 17 | {social.name} 18 |
  • 19 | {/each} 20 |
21 |
22 | 23 | 54 | -------------------------------------------------------------------------------- /src/lib/styles/_variables.scss: -------------------------------------------------------------------------------- 1 | /* stylelint-disable value-keyword-case */ 2 | :root { 3 | --ff-body: Inter Variable, body-fallback-1, body-fallback-2, body-fallback-3, body-fallback-4; 4 | --fw-body-regular: 400; 5 | --fw-body-bold: 900; 6 | --ff-mono: Source Code Pro Variable, mono-fallback; 7 | --fw-mono-regular: 500; 8 | /* https://www.fluid-type-scale.com/calculate?minFontSize=16&minWidth=400&minRatio=1.2&maxFontSize=19&maxWidth=1000&maxRatio=1.25&steps=3xs%2C2xs%2Cxs%2Csm%2Cbase%2Cmd%2Clg%2Cxl%2C2xl%2C3xl%2C4xl&baseStep=base&prefix=sp&decimals=2&useRems=on&remValue=16&previewFont=Inter */ 9 | --sp-3xs: clamp(0.48rem, 0.01vw + 0.48rem, 0.49rem); 10 | --sp-2xs: clamp(0.58rem, 0.08vw + 0.56rem, 0.61rem); 11 | --sp-xs: clamp(0.69rem, 0.17vw + 0.65rem, 0.76rem); 12 | --sp-sm: clamp(0.83rem, 0.31vw + 0.76rem, 0.95rem); 13 | --sp-base: clamp(1rem, 0.5vw + 0.88rem, 1.19rem); 14 | --sp-md: clamp(1.2rem, 0.76vw + 1.01rem, 1.48rem); 15 | --sp-lg: clamp(1.44rem, 1.11vw + 1.16rem, 1.86rem); 16 | --sp-xl: clamp(1.73rem, 1.58vw + 1.33rem, 2.32rem); 17 | --sp-2xl: clamp(2.07rem, 2.2vw + 1.52rem, 2.9rem); 18 | --sp-3xl: clamp(2.49rem, 3.03vw + 1.73rem, 3.62rem); 19 | --sp-4xl: clamp(2.99rem, 4.12vw + 1.96rem, 4.53rem); 20 | --ls-sm: -0.75px; 21 | --color-surface-dark: black; 22 | --color-surface-medium: hsl(0deg 0% 95%); 23 | --color-surface-light: white; 24 | --color-border: hsl(0deg 0% 60%); 25 | --color-danger: hsl(0, 83%, 43%); 26 | color-scheme: light; 27 | } 28 | -------------------------------------------------------------------------------- /src/lib/components/TypeScalePicker.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 24 | 25 | 39 | 40 | 41 | {#each Object.entries(typeScaleRatios) as [_key, { name, ratio }]} 42 | 45 | {/each} 46 | 47 | -------------------------------------------------------------------------------- /src/lib/components/App/Form/GroupRems.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 26 | 44 | -------------------------------------------------------------------------------- /src/lib/components/App/Form/GroupTypeScaleSteps.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
17 | 30 | 30 | 45 | form.set({ ...$form, [Param.maxRatio]: e.currentTarget.valueAsNumber })} 52 | /> 53 |
54 | -------------------------------------------------------------------------------- /src/lib/components/App/Form/GroupMinimum.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 |
18 | 30 | 45 | form.set({ ...$form, [Param.minRatio]: e.currentTarget.valueAsNumber })} 52 | /> 53 |
54 | -------------------------------------------------------------------------------- /src/lib/components/Select.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 | 25 | 41 | 42 | 52 | -------------------------------------------------------------------------------- /src/lib/components/GoogleFontSelect.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 53 | 54 | 45 | 46 | 67 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | {title} - {description} 44 | {@html ''} 45 | 46 | 47 |
48 | 49 | 50 | 51 |
52 | 53 | 64 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fluid-type-scale-calculator", 3 | "description": "Generate fluid typography variables with a modular type scale.", 4 | "version": "2.1.0", 5 | "license": "MIT", 6 | "author": { 7 | "name": "Aleksandr Hovhannisyan", 8 | "email": "aleksandrhovhannisyan@gmail.com", 9 | "url": "https://www.aleksandrhovhannisyan.com/" 10 | }, 11 | "engines": { 12 | "node": ">=22" 13 | }, 14 | "private": true, 15 | "type": "module", 16 | "scripts": { 17 | "clean": "rm -rf build .svelte-kit", 18 | "dev": "pnpm run clean && node --env-file=.env prebuild.js && vite dev", 19 | "build": "pnpm run clean && node prebuild.js && vite build", 20 | "build:local": "pnpm run clean && node --env-file=.env prebuild.js && vite build", 21 | "preview": "vite preview", 22 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 23 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 24 | "test": "vitest", 25 | "lint": "prettier --check . && eslint .", 26 | "format": "prettier --write ." 27 | }, 28 | "devDependencies": { 29 | "@sveltejs/adapter-cloudflare": "~4.7.2", 30 | "@sveltejs/kit": "~2.5.28", 31 | "@sveltejs/vite-plugin-svelte": "~3.0.0", 32 | "@types/eslint": "~8.56.0", 33 | "@types/node": "~20.12.12", 34 | "@typescript-eslint/eslint-plugin": "~7.0.0", 35 | "@typescript-eslint/parser": "~7.0.0", 36 | "clsx": "~2.1.1", 37 | "eslint": "~8.56.0", 38 | "eslint-config-prettier": "~9.1.0", 39 | "eslint-plugin-svelte": "~2.35.1", 40 | "prettier": "~3.1.1", 41 | "prettier-plugin-svelte": "~3.1.2", 42 | "sass": "~1.77.0", 43 | "svelte": "~4.2.7", 44 | "svelte-check": "~3.6.0", 45 | "sveltekit-superforms": "~2.27.4", 46 | "tslib": "~2.4.1", 47 | "typescript": "~5.0.0", 48 | "vite": "~5.0.3", 49 | "vitest": "~1.2.0" 50 | }, 51 | "dependencies": { 52 | "@fontsource-variable/inter": "~5.0.18", 53 | "@fontsource-variable/source-code-pro": "~5.0.19", 54 | "outdent": "~0.8.0", 55 | "valibot": "~1.0.0-rc.3" 56 | }, 57 | "browserslist": [ 58 | "last 2 chrome versions", 59 | "last 3 safari versions", 60 | "last 2 firefox versions", 61 | "last 2 edge versions" 62 | ], 63 | "packageManager": "pnpm@8.15.9+sha256.daa27a0b541bc635323ff96c2ded995467ff9fe6d69ff67021558aa9ad9dcc36" 64 | } 65 | -------------------------------------------------------------------------------- /src/lib/components/Label.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 |
20 | 26 | 27 |
28 | 29 | 77 | -------------------------------------------------------------------------------- /src/lib/components/Info.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 |
7 |

About this tool

8 |

9 | Design systems traditionally used static type scales, where the steps in the scale are not 10 | dependent on the viewport or container width. This means that developers have to write media 11 | queries to switch font sizes at mobile, tablet, and desktop breakpoints. But in practice, 12 | there are innumerable device widths and orientations, so there are always going to be edge 13 | cases where your font sizes are either too large or too small. 14 |

15 |

16 | By contrast, in a fluid type scale, each step has a minimum, maximum, and variable 17 | (viewport-dependent) font size. We can use CSS clamp and viewport width (vw) units to generate a set of font size variables that 20 | scale linearly. This means you no longer need to worry about fine-tuning your typography for 21 | discrete device widths: Just pick your minima and maxima, and the font sizes will scale 22 | fluidly in between. 23 |

24 |
25 |
26 |

Learn more

27 |
    28 |
  • 29 | 30 | Creating a Fluid Type Scale with CSS Clamp 31 | by yours truly! A deep dive into the math behind this technique. 32 |
  • 33 |
  • 34 | 35 | Modern Fluid Typography Editor 36 | , a handy tool by Adrian Bece that allows you to visualize a CSS clamp declaration. 37 |
  • 38 |
  • 39 | Type Scale - A Visual Calculator by Jeremy Church. 40 |
  • 41 |
  • 42 | Utopia Fluid Type Scale Calculator by 43 | James Gilyead and Trys Mudford. 44 |
  • 45 |
  • 46 | 47 | Consistent, Fluidly Scaling Type and Spacing 48 | by Andy Bell. 49 |
  • 50 |
51 |
52 |
53 | -------------------------------------------------------------------------------- /src/lib/components/GitHubCorner.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 |
9 | 13 | 25 | 26 |
27 | 28 | 76 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { GOOGLE_FONTS_BASE_URL } from './constants'; 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 4 | export function debounce unknown>( 5 | callback: Callback, 6 | delayMs: number 7 | ) { 8 | let timeoutId: number; 9 | return (...args: unknown[]) => { 10 | clearTimeout(timeoutId); 11 | // @ts-expect-error idk why it's typing this as NodeJs.Timeout 12 | timeoutId = setTimeout(() => callback(...args), delayMs); 13 | }; 14 | } 15 | 16 | /** Returns a value clamped between a min and a max value, inclusive. */ 17 | export const clamp = ({ 18 | value, 19 | min, 20 | max 21 | }: { 22 | /** The value to clamp. */ 23 | value: number; 24 | /** The minimum (inclusive) allowed value. */ 25 | min: number; 26 | /** The maximum (inclusive) allowed value. */ 27 | max: number; 28 | }) => Math.max(Math.min(value, max), min); 29 | 30 | /** Prefixes the given relative url string with the base site URL. */ 31 | export const toAbsoluteUrl = (url: string, baseUrl: string | URL) => 32 | new URL(url, baseUrl).toString(); 33 | 34 | /** Given a font family, returns the properly formatted href that can be used to link to that font's @font-face CSS on Google's servers. */ 35 | export const getGoogleFontLinkTagHref = (options: { 36 | family: string; 37 | display: 'auto' | 'block' | 'fallback' | 'optional' | 'swap'; 38 | }) => { 39 | const url = new URL(GOOGLE_FONTS_BASE_URL); 40 | url.search = new URLSearchParams(options).toString(); 41 | return url.toString(); 42 | }; 43 | 44 | /** Parses the given string to a comma-separated string array. */ 45 | export const toCommaSeparatedList = (rawValue: string): string[] => { 46 | return rawValue 47 | .split(',') 48 | .map((el) => el.trim()) 49 | .filter((el) => !!el); 50 | }; 51 | 52 | /** Indents the given text by a certain level. If the text is already indented, each line will be indented by the specified level. 53 | * Uses tabs by default, but you can also indent by spaces. 54 | */ 55 | export const indent = (text: string, indentLevel: number, indentChar: ' ' | '\t' = '\t') => { 56 | if (indentLevel <= 0) { 57 | throw new Error(`Invalid indentation level: ${indentLevel}`); 58 | } 59 | const tabIndent = Array.from({ length: indentLevel }, () => indentChar).join(''); 60 | return text 61 | .split('\n') 62 | .map((line) => `${tabIndent}${line}`) 63 | .join('\n'); 64 | }; 65 | 66 | /** Generates unique IDs for use in HTML. */ 67 | export const createId = (() => { 68 | let id = 0; 69 | return (prefix = 'uuid') => `${prefix}-${id++}`; 70 | })(); 71 | -------------------------------------------------------------------------------- /src/lib/components/App/App.svelte: -------------------------------------------------------------------------------- 1 | 33 | 34 |
updateUrlWithFormData(e.currentTarget)} 38 | use:enhance 39 | > 40 |
41 |
42 | 48 |
49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 60 |
61 |
62 | 63 |
64 | 65 | 66 | 67 | 93 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { clamp, getGoogleFontLinkTagHref, indent, toAbsoluteUrl } from '$lib/utils'; 3 | import { GOOGLE_FONTS_BASE_URL } from '$lib/constants'; 4 | 5 | describe('App-wide utilities', () => { 6 | describe('clamp', () => { 7 | it('clamps a value between a min and max', () => { 8 | expect(clamp({ value: 5, min: 0, max: 10 })).toStrictEqual(5); 9 | expect(clamp({ value: 0, min: 0, max: 10 })).toStrictEqual(0); 10 | expect(clamp({ value: 10, min: 0, max: 10 })).toStrictEqual(10); 11 | expect(clamp({ value: 0, min: 1, max: 10 })).toStrictEqual(1); 12 | expect(clamp({ value: 11, min: 0, max: 10 })).toStrictEqual(10); 13 | }); 14 | }); 15 | describe('toAbsoluteUrl', () => { 16 | it('handles relative paths that start with a slash', () => { 17 | expect(toAbsoluteUrl('/some/path/', 'https://fluid-type-scale.com')).toEqual( 18 | `https://fluid-type-scale.com/some/path/` 19 | ); 20 | }); 21 | it('handles site URL that has a trailing slash', () => { 22 | expect(toAbsoluteUrl('some/path/', 'https://fluid-type-scale.com/')).toEqual( 23 | `https://fluid-type-scale.com/some/path/` 24 | ); 25 | }); 26 | it('handles both site URL with trailing slash and url with preceding slash', () => { 27 | expect(toAbsoluteUrl('/some/path/', 'https://fluid-type-scale.com/')).toEqual( 28 | `https://fluid-type-scale.com/some/path/` 29 | ); 30 | }); 31 | }); 32 | describe('getGoogleFontLinkTagHref', () => { 33 | it('properly escapes spaces in font family names', () => { 34 | expect( 35 | getGoogleFontLinkTagHref({ family: 'Libre Baskerville', display: 'swap' }) 36 | ).toStrictEqual(`${GOOGLE_FONTS_BASE_URL}?family=Libre+Baskerville&display=swap`); 37 | }); 38 | it('respects the display property', () => { 39 | expect( 40 | getGoogleFontLinkTagHref({ family: 'Libre Baskerville', display: 'fallback' }) 41 | ).toStrictEqual(`${GOOGLE_FONTS_BASE_URL}?family=Libre+Baskerville&display=fallback`); 42 | }); 43 | }); 44 | describe('indent', () => { 45 | it('throws if indentation is invalid', () => { 46 | expect(() => indent('text', 0)).toThrow(); 47 | expect(() => indent('text', -1)).toThrow(); 48 | }); 49 | it('indents to >= 1 level', () => { 50 | expect(indent('one line', 1, '\t')).toStrictEqual('\tone line'); 51 | expect(indent('one line', 1, ' ')).toStrictEqual(' one line'); 52 | expect(indent('one line', 2, '\t')).toStrictEqual('\t\tone line'); 53 | expect(indent('one line', 2, ' ')).toStrictEqual(' one line'); 54 | }); 55 | it('indents multiple lines', () => { 56 | expect(indent('1\n2\n3', 2, '\t')).toStrictEqual('\t\t1\n\t\t2\n\t\t3'); 57 | }); 58 | it('indents already indented lines', () => { 59 | expect(indent('\tone line', 1, '\t')).toStrictEqual('\t\tone line'); 60 | expect(indent(' one line', 1, ' ')).toStrictEqual(' one line'); 61 | }); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /src/lib/components/App/Output.svelte: -------------------------------------------------------------------------------- 1 | 68 | 69 |
70 |
71 | 72 | {getCode()} 73 | 74 |
75 | {#if !isInvalid} 76 | 77 | {/if} 78 |
79 | 80 | 113 | -------------------------------------------------------------------------------- /src/lib/schema/schema.utils.ts: -------------------------------------------------------------------------------- 1 | import type { TypeScale } from '$lib/types'; 2 | import { clamp, toCommaSeparatedList } from '$lib/utils'; 3 | import { getContext, setContext } from 'svelte'; 4 | import { type Schema, type SchemaSuperform, FORM_CONTEXT_KEY, schema } from './schema'; 5 | import { superForm, type SuperValidated } from 'sveltekit-superforms'; 6 | import { valibot } from 'sveltekit-superforms/adapters'; 7 | 8 | export const useFormState = () => getContext(FORM_CONTEXT_KEY); 9 | 10 | /** Given a form state representing user input for the various parameters, returns 11 | * the corresponding type scale mapping each step to its min/max/preferred font sizes. 12 | */ 13 | export const getTypeScale = (state: Schema): TypeScale => { 14 | /** Appends the correct unit to a unitless value. */ 15 | const withUnit = (unitlessValue: number) => `${unitlessValue}${state.useRems ? 'rem' : 'px'}`; 16 | 17 | /** Rounds the given value to a fixed number of decimal places, according to the user's specified value. */ 18 | const round = (val: number) => Number(val.toFixed(state.decimals)); 19 | 20 | /** If we're using rems, converts the pixel arg to rems. Else, keeps it in pixels. */ 21 | const convertToDesiredUnit = (px: number) => (state.useRems ? px / state.remValue : px); 22 | 23 | const allSteps = toCommaSeparatedList(state.steps); 24 | 25 | // Get the index of the base modular step to compute exponents relative to the base index (up/down) 26 | const baseStepIndex = allSteps.indexOf(state.baseStep); 27 | 28 | // Reshape the data so we map each step name to a config describing its fluid font sizing values. 29 | // Do this on every render because it's essentially derived state; no need for a useEffect. 30 | // Note that some state variables are not necessary for this calculation, but it's simple enough that it's not expensive. 31 | const typeScale = allSteps.reduce((steps, step, i) => { 32 | const min = { 33 | fontSize: state.minFontSize * Math.pow(state.minRatio, i - baseStepIndex), 34 | breakpoint: state.minWidth 35 | }; 36 | const max = { 37 | fontSize: state.maxFontSize * Math.pow(state.maxRatio, i - baseStepIndex), 38 | breakpoint: state.maxWidth 39 | }; 40 | const slope = (max.fontSize - min.fontSize) / (max.breakpoint - min.breakpoint); 41 | const slopeVw = `${round(slope * 100)}${state.useContainerWidth ? 'cqi' : 'vi'}`; 42 | const intercept = min.fontSize - slope * min.breakpoint; 43 | 44 | steps.set(step, { 45 | min: withUnit(round(convertToDesiredUnit(min.fontSize))), 46 | max: withUnit(round(convertToDesiredUnit(max.fontSize))), 47 | preferred: `${slopeVw} + ${withUnit(round(convertToDesiredUnit(intercept)))}`, 48 | getFontSizeAtScreenWidth: (width: number) => { 49 | let preferredFontSize = width * slope + intercept; 50 | preferredFontSize = clamp({ 51 | value: preferredFontSize, 52 | min: min.fontSize, 53 | max: max.fontSize 54 | }); 55 | return withUnit(round(convertToDesiredUnit(preferredFontSize))); 56 | } 57 | }); 58 | return steps; 59 | // NOTE: Using a Map instead of an object to preserve key insertion order. 60 | }, new Map() as TypeScale); 61 | 62 | return typeScale; 63 | }; 64 | 65 | /** Initializes client-side form context. */ 66 | export const initClientSideForm = (form: SuperValidated) => { 67 | const superform = superForm(form, { 68 | validators: valibot(schema), 69 | validationMethod: 'auto', 70 | errorSelector: 'input[aria-invalid="true"]', 71 | // Doesn't seem to work, but docs here: https://superforms.rocks/concepts/error-handling#autofocusonerror 72 | autoFocusOnError: true 73 | }); 74 | setContext(FORM_CONTEXT_KEY, superform); 75 | }; 76 | -------------------------------------------------------------------------------- /src/lib/components/App/Preview.svelte: -------------------------------------------------------------------------------- 1 | 32 | 33 | 34 | {#if !isDefaultFontFamily} 35 | 43 | {/if} 44 | 45 |
46 |

Preview your type scale

47 |
48 | 58 | 73 | 84 | form.set({ 85 | ...$form, 86 | [Param.previewWidth]: e.currentTarget.valueAsNumber 87 | })} 88 | /> 89 | 92 |
93 |
94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | {#each typeScaleEntries as [step, { min, max, getFontSizeAtScreenWidth }]} 106 | 107 | 108 | 109 | 110 | 111 | 121 | 122 | {/each} 123 | 124 |
Step Min Max Rendered Preview
{step}{min}{max}{getFontSizeAtScreenWidth(previewWidth)} 119 | {previewText} 120 |
125 |
126 |
127 | 128 | 173 | -------------------------------------------------------------------------------- /src/lib/schema/schema.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | import fonts from '../data/fonts.json'; 3 | import typeScaleRatios from '../data/typeScaleRatios.json'; 4 | import { COMMA_SEPARATED_LIST_REGEX, CSS_VARIABLE_REGEX } from '$lib/constants'; 5 | import { toCommaSeparatedList } from '$lib/utils'; 6 | import type { InputConstraints, SuperForm, ValidationErrors } from 'sveltekit-superforms'; 7 | 8 | export const FORM_CONTEXT_KEY = 'form-state'; 9 | 10 | /** A recognized query param ID. Also used on the front end by form inputs to set their `name` attribute to the corresponding query param. */ 11 | export enum Param { 12 | minFontSize = 'minFontSize', 13 | minWidth = 'minWidth', 14 | minRatio = 'minRatio', 15 | maxFontSize = 'maxFontSize', 16 | maxWidth = 'maxWidth', 17 | maxRatio = 'maxRatio', 18 | allSteps = 'steps', 19 | baseStep = 'baseStep', 20 | namingConvention = 'prefix', 21 | shouldUseRems = 'useRems', 22 | shouldUseContainerWidth = 'useContainerWidth', 23 | shouldIncludeFallbacks = 'includeFallbacks', 24 | remValueInPx = 'remValue', 25 | roundingDecimalPlaces = 'decimals', 26 | previewFont = 'previewFont', 27 | previewText = 'previewText', 28 | previewWidth = 'previewWidth' 29 | } 30 | 31 | /** Schema for validating and parsing all query parameters recognized by the app. Used on the server side to read query params on the /calculate route. */ 32 | export const schema = v.pipe( 33 | v.object({ 34 | [Param.minFontSize]: v.pipe(v.number(), v.minValue(1)), 35 | [Param.minWidth]: v.pipe(v.number(), v.minValue(1)), 36 | [Param.minRatio]: v.pipe(v.number(), v.minValue(Number.MIN_VALUE, 'Expected positive number')), 37 | [Param.maxFontSize]: v.pipe(v.number(), v.minValue(1)), 38 | [Param.maxWidth]: v.pipe(v.number(), v.minValue(1)), 39 | [Param.maxRatio]: v.pipe(v.number(), v.minValue(Number.MIN_VALUE, 'Expected positive number')), 40 | [Param.allSteps]: v.pipe( 41 | v.string(), 42 | v.regex(COMMA_SEPARATED_LIST_REGEX, 'Expected comma-separated list of names') 43 | ), 44 | [Param.baseStep]: v.pipe(v.string(), v.minLength(1, 'Expected non-empty value')), 45 | [Param.namingConvention]: v.pipe( 46 | v.string(), 47 | v.minLength(1, 'Expected non-empty value'), 48 | v.regex(CSS_VARIABLE_REGEX, 'Invalid characters used') 49 | ), 50 | // IMPORTANT: Checkboxes must be marked optional because form submissions omit unchecked inputs from the POSTed FormData 51 | [Param.shouldUseContainerWidth]: v.pipe(v.optional(v.boolean())), 52 | [Param.shouldIncludeFallbacks]: v.pipe(v.optional(v.boolean())), 53 | [Param.shouldUseRems]: v.pipe(v.optional(v.boolean())), 54 | [Param.remValueInPx]: v.pipe(v.number(), v.minValue(1, 'Value must be >= 1')), 55 | [Param.roundingDecimalPlaces]: v.pipe(v.number(), v.minValue(0), v.maxValue(10), v.integer()), 56 | [Param.previewFont]: v.pipe(v.string(), v.minLength(1, 'Expected non-empty value')), 57 | [Param.previewText]: v.pipe(v.string(), v.minLength(1, 'Expected non-empty value')), 58 | [Param.previewWidth]: v.pipe( 59 | v.number(), 60 | v.minValue(Number.MIN_VALUE, 'Expected positive number') 61 | ) 62 | }), 63 | v.forward( 64 | v.partialCheck( 65 | [[Param.minFontSize], [Param.maxFontSize]], 66 | (schema) => schema[Param.minFontSize] < schema[Param.maxFontSize], 67 | 'Min font size must be less than max font size' 68 | ), 69 | [Param.minFontSize] 70 | ), 71 | v.forward( 72 | v.partialCheck( 73 | [[Param.minWidth], [Param.maxWidth]], 74 | (schema) => schema[Param.minWidth] < schema[Param.maxWidth], 75 | 'Min width must be less than max width' 76 | ), 77 | [Param.minWidth] 78 | ), 79 | v.forward( 80 | v.partialCheck( 81 | [[Param.minRatio], [Param.maxRatio]], 82 | (schema) => schema[Param.minRatio] < schema[Param.maxRatio], 83 | 'Min ratio must be less than max ratio' 84 | ), 85 | [Param.minRatio] 86 | ), 87 | v.forward( 88 | v.partialCheck( 89 | [[Param.minFontSize], [Param.maxFontSize]], 90 | (schema) => schema[Param.minFontSize] < schema[Param.maxFontSize], 91 | 'Max font size must be greater than min font size' 92 | ), 93 | [Param.maxFontSize] 94 | ), 95 | v.forward( 96 | v.partialCheck( 97 | [[Param.minWidth], [Param.maxWidth]], 98 | (schema) => schema[Param.minWidth] < schema[Param.maxWidth], 99 | 'Max width must be greater than min width' 100 | ), 101 | [Param.maxWidth] 102 | ), 103 | v.forward( 104 | v.partialCheck( 105 | [[Param.minRatio], [Param.maxRatio]], 106 | (schema) => schema[Param.minRatio] < schema[Param.maxRatio], 107 | 'Max ratio must be greater than min ratio' 108 | ), 109 | [Param.maxRatio] 110 | ), 111 | v.forward( 112 | v.partialCheck( 113 | [[Param.allSteps], [Param.baseStep]], 114 | (schema) => { 115 | const steps = toCommaSeparatedList(schema[Param.allSteps]); 116 | return steps.includes(schema[Param.baseStep]); 117 | }, 118 | 'Base step must appear in the list of all steps' 119 | ), 120 | [Param.baseStep] 121 | ), 122 | v.forward( 123 | v.partialCheck( 124 | [[Param.allSteps]], 125 | (schema) => { 126 | const steps = toCommaSeparatedList(schema[Param.allSteps]); 127 | return new Set(steps).size === steps.length; 128 | }, 129 | 'Cannot have duplicate step names' 130 | ), 131 | [Param.allSteps] 132 | ), 133 | v.forward( 134 | v.partialCheck( 135 | [[Param.previewFont]], 136 | (schema) => fonts.includes(schema[Param.previewFont]), 137 | 'Unrecognized Google font' 138 | ), 139 | [Param.previewFont] 140 | ) 141 | ); 142 | 143 | /** Returns all the default values for the schema. */ 144 | export const SCHEMA_DEFAULTS: Schema = { 145 | [Param.minFontSize]: 16, 146 | [Param.minWidth]: 400, 147 | [Param.minRatio]: typeScaleRatios.majorThird.ratio, 148 | [Param.maxFontSize]: 19, 149 | [Param.maxWidth]: 1280, 150 | [Param.maxRatio]: typeScaleRatios.perfectFourth.ratio, 151 | [Param.allSteps]: 'sm,base,md,lg,xl,xxl,xxxl', 152 | [Param.baseStep]: 'base', 153 | [Param.namingConvention]: 'fs', 154 | [Param.shouldUseContainerWidth]: false, 155 | [Param.shouldIncludeFallbacks]: false, 156 | [Param.shouldUseRems]: true, 157 | [Param.remValueInPx]: 16, 158 | [Param.roundingDecimalPlaces]: 2, 159 | [Param.previewFont]: 'Inter', 160 | [Param.previewText]: 'Almost before we knew it, we had left the ground', 161 | [Param.previewWidth]: 1280 162 | }; 163 | 164 | /** The default font family used by the app and shown in font pickers. */ 165 | export const DEFAULT_FONT_FAMILY = SCHEMA_DEFAULTS['previewFont']; 166 | 167 | export type Schema = v.InferInput; 168 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 169 | export type SchemaSuperform = SuperForm; 170 | export type SchemaErrors = ValidationErrors[keyof ValidationErrors]; 171 | export type SchemaConstraints = InputConstraints[keyof InputConstraints]; 172 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fluid Type Scale Calculator 2 | 3 | > Generate font size variables for a fluid type scale with CSS clamp. 4 | 5 | ## Overview 6 | 7 | Customize everything, grab the output CSS, and drop it into any design system. Share the URL with your team members or use it to document your CSS. 8 | 9 | ![](./static/images/thumbnail.png) 10 | 11 | ### Features 12 | 13 | - Fully customizable parameters for everything: 14 | - Baseline min/max font size, screen width, type scale. 15 | - The names of the steps in your type scale. 16 | - The prefix to use for the variable names. 17 | - The max number of decimal places in the output. 18 | - Whether to show output in rems or pixels. 19 | - The resolved pixel value of 1rem. 20 | - Output CSS variables for fluid font sizing. 21 | - Live preview table. Pick a font and enter some sample text to fine-tune the results. 22 | - Link sharing. 23 | 24 | ### Link Sharing 25 | 26 | The `/calculate` route accepts the following query parameters and types. All parameters except boolean flags are required. Booleans that are not specified in the URL are treated as unchecked (`false`). 27 | 28 | | Param | Description | Type | Constraints | Default | 29 | | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | 30 | | `minFontSize` | The minimum font size for the base step | `number` | `> 0 && < maxFontSize` | `16` | 31 | | `minWidth` | The viewport or container width corresponding to the minimum font size | `number` | `> 0 && < maxWidth` | `400` | 32 | | `minRatio` | The type scale ratio for the minimum font size of each step | `number` | `> 0 && < maxRatio` | `1.25` | 33 | | `maxFontSize` | The maximum font size for the base step | `number` | `> 0 && > minFontSize` | `19` | 34 | | `maxWidth` | The viewport or container width corresponding to the maximum font size | `number` | `> 0 && > minWidth` | `1280` | 35 | | `maxRatio` | The type scale ratio for the maximum font size of each step | `number` | `> 0 && > minRatio` | `1.333` | 36 | | `steps` | A comma-separated list of names for your type scale steps, in ascending order of font size | `string` | Comma-separated list. Step names must be alphanumeric, with no spaces. No duplicates are allowed. | `sm,base,md,lg,xl,xxl,xxxl` | 37 | | `baseStep` | The name of the base step | `string` | Non-empty string. Must appear in `steps`. | `base` | 38 | | `prefix` | The naming convention to use for the output CSS variables | `string` | Non-empty alphanumeric string. | `font-size` | 39 | | `includeFallbacks` | Whether to include fallback variables in the CSS output for browsers that don't support clamp. | `on`, `true`, or `false` | | `false` | 40 | | `useRems` | Whether to use rems for font sizing. | `on`, `true`, or `false` | | `true` | 41 | | `useContainerWidth` | Whether to use container width (cqi) instead of viewport width. | `on`, `true`, or `false` | | `false` | 42 | | `remValue` | The computed pixel value of `1rem`. Useful if your app changes the root font size (e.g., with the popular [`62.5%` font size trick](https://www.aleksandrhovhannisyan.com/blog/62-5-percent-font-size-trick/)). Note: This parameter has no effect if `useRems` is omitted. | `number` | `> 0` | `16` | 43 | | `decimals` | The number of decimal places to round the output to. | `int` | `>= 0 && <= 10` | `2` | 44 | | `previewFont` | The font family to render in the preview. | `string` | Non-empty string. Spaces must be escaped (e.g., `Libre+Baskerville`). The font must be a valid Google Font. Custom fonts are not supported. | `Inter` | 45 | | `previewText` | The text to render in the preview table. | `string` | Non-empty string. Spaces must be escaped (e.g., `This+is+a+sentence`). | `Almost before we knew it, we had left the ground` | 46 | | `previewWidth` | The width to simulate in the preview table. | `number` | `> 0` | N/A | 47 | 48 | Example URL: `https://www.fluid-type-scale.com/calculate?minFontSize=15&minWidth=400&minRatio=1.25&maxFontSize=17&maxWidth=1280&maxRatio=1.333&steps=sm%2Cbase%2Cmd%2Clg%2Cxl%2Cxxl%2Cxxxl&baseStep=base&prefix=font-size&decimals=2&useRems=on&remValue=10&previewFont=Libre+Baskerville&previewText=Testing+123&previewWidth=420` 49 | 50 | ### Tech Stack 51 | 52 | - SvelteKit 53 | - Sass 54 | - TypeScript 55 | 56 | ### Running Locally 57 | 58 | 1. Clone the repo. 59 | 2. Run `pnpm install` to install dependencies. 60 | 3. Run `pnpm run dev` and visit `localhost:5173` to view the app. 61 | 62 | ## Similar Tools 63 | 64 | - [Utopia.fyi fluid type scale calculator](https://utopia.fyi/type/calculator/) by James Gilyead and Trys Mudford 65 | - [Type Scale](https://type-scale.com/) by Jeremy Church 66 | - [Modern fluid typography editor](https://modern-fluid-typography.vercel.app/) by Adrian Bece 67 | - [Fluid Typography](https://fluid-typography.netlify.app/) by Erik André Jakobsen 68 | - [fluidtypography.com](https://fluidtypography.com/) by Kip Hughes 69 | 70 | ## Learn More 71 | 72 | - [Creating a Fluid Type Scale with CSS Clamp](https://www.aleksandrhovhannisyan.com/blog/fluid-type-scale-with-css-clamp/), a deep dive I wrote on this topic. The technique covered in the article is the basis for this app. 73 | - [Generating `font-size` CSS Rules and Creating a Fluid Type Scale](https://moderncss.dev/generating-font-size-css-rules-and-creating-a-fluid-type-scale/) by Stephanie Eckles 74 | - [Consistent, Fluidly Scaling Type and Spacing](https://css-tricks.com/consistent-fluidly-scaling-type-and-spacing/) by Andy Bell 75 | --------------------------------------------------------------------------------