├── .gitattributes ├── src ├── vite-env.d.ts ├── main.tsx ├── components │ ├── page-title.tsx │ ├── markdown-view.tsx │ ├── main-content.tsx │ ├── markdown-view.css │ ├── heading.tsx │ ├── markdown-outline-item.tsx │ ├── markdown-outline-view.tsx │ └── markdown-outline-active-section-highlight.tsx ├── atoms │ └── container.tsx ├── index.css ├── app.css ├── utils │ └── mdast-extract-headings.ts ├── app.tsx ├── stores │ ├── logger.ts │ ├── content.ts │ └── toc.ts ├── hooks │ └── use-visible-sections.ts ├── markdown-renderer.ts ├── assets │ └── react.svg ├── example.md └── tokens.css ├── bun.lockb ├── images ├── screenshot.png └── video-tutorial.png ├── tsconfig.json ├── kuma.config.ts ├── .gitignore ├── index.html ├── .prettierrc.json ├── vite.config.ts ├── tsconfig.node.json ├── tsconfig.app.json ├── README.md ├── eslint.config.js ├── package.json └── public └── vite.svg /.gitattributes: -------------------------------------------------------------------------------- 1 | *.lockb binary diff=lockb 2 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/craftzdog/smooth-toc-example/HEAD/bun.lockb -------------------------------------------------------------------------------- /images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/craftzdog/smooth-toc-example/HEAD/images/screenshot.png -------------------------------------------------------------------------------- /images/video-tutorial.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/craftzdog/smooth-toc-example/HEAD/images/video-tutorial.png -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import App from './app.tsx' 4 | import './index.css' 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | 10 | ) 11 | -------------------------------------------------------------------------------- /src/components/page-title.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Heading } from '@kuma-ui/core' 3 | import { useContentStore } from '@/stores/content' 4 | 5 | export const PageTitle: React.FC = () => { 6 | const { title } = useContentStore() 7 | 8 | return {title} 9 | } 10 | -------------------------------------------------------------------------------- /kuma.config.ts: -------------------------------------------------------------------------------- 1 | import { createTheme } from '@kuma-ui/core' 2 | 3 | const theme = createTheme({ 4 | radii: { 5 | sm: '0.5rem', 6 | md: '1rem' 7 | } 8 | }) 9 | 10 | type UserTheme = typeof theme 11 | 12 | declare module '@kuma-ui/core' { 13 | export interface Theme extends UserTheme {} 14 | } 15 | 16 | export default theme 17 | -------------------------------------------------------------------------------- /.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 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/atoms/container.tsx: -------------------------------------------------------------------------------- 1 | import { Box, BoxProps } from '@kuma-ui/core' 2 | 3 | interface Props extends BoxProps {} 4 | 5 | export const Container = ({ children, ...boxProps }: Props) => { 6 | return ( 7 | 14 | {children} 15 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "singleQuote": true, 4 | "bracketSpacing": true, 5 | "endOfLine": "lf", 6 | "semi": false, 7 | "tabWidth": 2, 8 | "trailingComma": "none", 9 | "plugins": ["@ianvs/prettier-plugin-sort-imports"], 10 | "importOrder": [ 11 | "", 12 | "^react", 13 | "", 14 | "^@inkdropapp", 15 | "^@[/]", 16 | "^[.].*$", 17 | "", 18 | "^[/].*$", 19 | "[.]css$" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'url' 2 | import KumaUI from '@kuma-ui/vite' 3 | import react from '@vitejs/plugin-react' 4 | import { defineConfig } from 'vite' 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | plugins: [ 9 | react(), 10 | KumaUI({ 11 | wasm: true 12 | }) 13 | ], 14 | resolve: { 15 | alias: [ 16 | { 17 | find: '@', 18 | replacement: fileURLToPath(new URL('./src', import.meta.url)) 19 | } 20 | ] 21 | } 22 | }) 23 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["ES2023"], 5 | "module": "ESNext", 6 | "skipLibCheck": true, 7 | 8 | /* Bundler mode */ 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "isolatedModules": true, 12 | "moduleDetection": "force", 13 | "noEmit": true, 14 | 15 | /* Linting */ 16 | "strict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noFallthroughCasesInSwitch": true 20 | }, 21 | "include": ["vite.config.ts"] 22 | } 23 | -------------------------------------------------------------------------------- /src/components/markdown-view.tsx: -------------------------------------------------------------------------------- 1 | import './markdown-view.css' 2 | import React, { useEffect } from 'react' 3 | import { Box } from '@kuma-ui/core' 4 | import markdownContent from '@/example.md?raw' 5 | import { useVisibleSections } from '@/hooks/use-visible-sections' 6 | import { useContentStore } from '@/stores/content' 7 | 8 | export const MarkdownView: React.FC = () => { 9 | const { dom, render } = useContentStore() 10 | 11 | useEffect(() => { 12 | render(markdownContent) 13 | }, [render]) 14 | 15 | useVisibleSections() 16 | 17 | return {dom ? dom : 'rendering...'} 18 | } 19 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @layer reset, 2 | base, 3 | tokens, 4 | recipes, 5 | utilities, 6 | theme; 7 | 8 | @import 'modern-normalize' layer(reset); 9 | @import './tokens.css'; 10 | 11 | :root { 12 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 13 | line-height: 1.5; 14 | font-weight: 400; 15 | 16 | color-scheme: dark; 17 | 18 | font-synthesis: none; 19 | text-rendering: optimizeLegibility; 20 | -webkit-font-smoothing: antialiased; 21 | -moz-osx-font-smoothing: grayscale; 22 | } 23 | 24 | html { 25 | margin: 0; 26 | } 27 | 28 | body { 29 | margin: 0; 30 | width: 100vw; 31 | min-height: 100vh; 32 | } 33 | -------------------------------------------------------------------------------- /src/app.css: -------------------------------------------------------------------------------- 1 | #root { 2 | display: contents; 3 | } 4 | 5 | :root { 6 | --page-background: var(--color-gray-950); 7 | --color-fg: var(--color-gray-200); 8 | --color-primary: var(--color-blue-500); 9 | --color-active: var(--color-pink-500); 10 | --color-muted: hsl(var(--hsl-gray-200) / 10%); 11 | --main-content-bg: var(--color-gray-900); 12 | --main-content-box-shadow: hsl(var(--hsl-gray-50) / 0.14) 0px 0px 0px 1px; 13 | --outline-active-fg: var(--color-fg); 14 | --outline-default-fg: var(--color-gray-500); 15 | } 16 | 17 | html { 18 | scroll-behavior: smooth; 19 | } 20 | 21 | body { 22 | background: var(--page-background); 23 | color: var(--color-fg); 24 | } 25 | -------------------------------------------------------------------------------- /src/components/main-content.tsx: -------------------------------------------------------------------------------- 1 | import { Box, BoxProps } from '@kuma-ui/core' 2 | 3 | interface Props extends BoxProps {} 4 | 5 | export const MainContent = ({ children, ...boxProps }: Props) => { 6 | return ( 7 | 8 | 21 | {children} 22 | 23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/components/markdown-view.css: -------------------------------------------------------------------------------- 1 | .markdown-view { 2 | margin: 0; 3 | line-height: 1.5; 4 | word-wrap: break-word; 5 | padding: 1em 2em; 6 | 7 | h1, 8 | h2, 9 | h3, 10 | h4, 11 | h5, 12 | h6 { 13 | margin-top: 24px; 14 | margin-bottom: 16px; 15 | font-weight: 600; 16 | line-height: 1.25; 17 | } 18 | 19 | h2 { 20 | padding-bottom: 0.3em; 21 | font-size: 1.5em; 22 | } 23 | 24 | h3 { 25 | font-size: 1.25em; 26 | } 27 | 28 | h4 { 29 | font-size: 1em; 30 | } 31 | 32 | h5 { 33 | font-size: 0.875em; 34 | } 35 | 36 | h6 { 37 | font-size: 0.85em; 38 | } 39 | 40 | p { 41 | margin-top: 0; 42 | margin-bottom: 10px; 43 | } 44 | 45 | pre { 46 | overflow: auto; 47 | } 48 | 49 | pre > code { 50 | padding: 0; 51 | margin: 0; 52 | word-break: normal; 53 | white-space: pre; 54 | background: transparent; 55 | border: 0; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/components/heading.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | import { useTocStore } from '@/stores/toc' 3 | 4 | type Props = React.DetailedHTMLProps< 5 | React.HTMLAttributes, 6 | HTMLHeadingElement 7 | > 8 | 9 | export const createRehypeHeading = (level: number) => { 10 | const RehypeHeading = (props: Props) => { 11 | const { id, children } = props 12 | const refHeading = useRef(null) 13 | const registerHeading = useTocStore(state => state.registerHeading) 14 | 15 | const HeadingTag = `h${level || 1}` as 16 | | 'h1' 17 | | 'h2' 18 | | 'h3' 19 | | 'h4' 20 | | 'h5' 21 | | 'h6' 22 | 23 | useEffect(() => { 24 | if (id) registerHeading(id, refHeading) 25 | }, [id, registerHeading]) 26 | 27 | return ( 28 | 29 | {children} 30 | 31 | ) 32 | } 33 | 34 | return (props: Props) => 35 | } 36 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "esModuleInterop": true, 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "moduleDetection": "force", 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | 19 | /* Linting */ 20 | "strict": true, 21 | "noUnusedLocals": false, 22 | "noImplicitAny": false, 23 | "noUnusedParameters": true, 24 | "noFallthroughCasesInSwitch": true, 25 | 26 | "baseUrl": "./" /* Base directory to resolve non-absolute module names. */, 27 | "paths": { 28 | /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 29 | "@/*": ["src/*"] 30 | } 31 | }, 32 | "include": ["src"] 33 | } 34 | -------------------------------------------------------------------------------- /src/utils/mdast-extract-headings.ts: -------------------------------------------------------------------------------- 1 | import Slugger from 'github-slugger' 2 | import { toString } from 'mdast-util-to-string' 3 | import { visit } from 'unist-util-visit' 4 | import type { Root } from 'mdast' 5 | 6 | const slugs = new Slugger() 7 | 8 | export type TOCHeading = { 9 | value: string 10 | id: string 11 | level: number 12 | } 13 | 14 | export const mdastExtractHeadings = ( 15 | mdast: Root, 16 | { maxDepth }: { maxDepth: number } = { maxDepth: 3 } 17 | ) => { 18 | slugs.reset() 19 | const headings: TOCHeading[] = [] 20 | 21 | visit(mdast, 'heading', function (node, _position, _parent) { 22 | const value = toString(node, { includeImageAlt: false }) 23 | const id = 24 | node.data && node.data.hProperties && (node.data.hProperties.id as string) 25 | const slug = slugs.slug(id || value) 26 | 27 | if (node.depth <= maxDepth) { 28 | headings.push({ 29 | value, 30 | id: slug, 31 | level: node.depth 32 | }) 33 | } 34 | }) 35 | 36 | return headings 37 | } 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Smoothly animated table of contents example 2 | 3 | ![screenshot](./images/screenshot.png) 4 | 5 | This is a demo project of a smoothly animated table of contents. 6 | It is a simple web app rendering Markdown content with a table of contents that smoothly highlights currently active headings. 7 | 8 | For more details, check out [the blog post](https://www.devas.life/how-to-build-a-smoothly-animated-table-of-contents-with-framer-motion-and-kuma-ui/) and the video tutorial: 9 | 10 | [![](./images/video-tutorial.png)](https://youtu.be/4g26x6FzuBU) 11 | 12 | ## Stack 13 | 14 | - [Bun — A fast all-in-one JavaScript runtime](https://bun.sh/) 15 | - [`framer-motion`](https://www.framer.com/motion/) for animations. 16 | - [`zustand`](https://zustand-demo.pmnd.rs/) for state management. 17 | - [`kuma-ui`](https://www.kuma-ui.com/) for building UI components 18 | - [Installation | Kuma UI](https://www.kuma-ui.com/docs/install) 19 | - `prettier` for formtting code 20 | - [`modern-normalize`](https://github.com/sindresorhus/modern-normalize) for normalizing browsers' default style 21 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import reactHooks from 'eslint-plugin-react-hooks' 3 | import reactRefresh from 'eslint-plugin-react-refresh' 4 | import globals from 'globals' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | '@typescript-eslint/no-explicit-any': 'off', 23 | '@typescript-eslint/no-empty-object-type': 'off', 24 | '@typescript-eslint/no-unused-vars': [ 25 | 'warn', 26 | { 27 | argsIgnorePattern: '^_', 28 | varsIgnorePattern: '^_' 29 | } 30 | ], 31 | 'react-refresh/only-export-components': [ 32 | 'warn', 33 | { allowConstantExport: true } 34 | ] 35 | } 36 | } 37 | ) 38 | -------------------------------------------------------------------------------- /src/app.tsx: -------------------------------------------------------------------------------- 1 | import './app.css' 2 | import { Box, HStack } from '@kuma-ui/core' 3 | import { MarkdownView } from '@/components/markdown-view' 4 | import { Container } from './atoms/container' 5 | import { MainContent } from './components/main-content' 6 | import { MarkdownOutlineView } from './components/markdown-outline-view' 7 | import { PageTitle } from './components/page-title' 8 | 9 | function App() { 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 24 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | ) 37 | } 38 | 39 | export default App 40 | -------------------------------------------------------------------------------- /src/components/markdown-outline-item.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | import { Box, Link } from '@kuma-ui/core' 3 | import { useTocStore } from '@/stores/toc' 4 | import { TOCHeading } from '@/utils/mdast-extract-headings' 5 | 6 | type Props = TOCHeading & { active: boolean } 7 | 8 | export const MarkdownOutlineItem = (props: Props) => { 9 | const { id, level, value, active } = props 10 | const refItem = useRef(null) 11 | const registerOutlineItem = useTocStore(state => state.registerOutlineItem) 12 | 13 | useEffect(() => { 14 | if (id) registerOutlineItem(id, refItem) 15 | }, [id, registerOutlineItem]) 16 | 17 | return ( 18 | 27 | 28 | 36 | {value} 37 | 38 | 39 | 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /src/stores/logger.ts: -------------------------------------------------------------------------------- 1 | import type { StoreApi } from 'zustand' 2 | 3 | type GetState = StoreApi['getState'] 4 | type SetState = StoreApi['setState'] 5 | 6 | type ConfigFn = ( 7 | set: SetState, 8 | get: GetState, 9 | api: StoreApi 10 | ) => T 11 | 12 | const enableLogging = import.meta.env.DEV 13 | 14 | export const loggerMiddleware = 15 | (config: ConfigFn) => 16 | (set: SetState, get: GetState, api: StoreApi) => 17 | config( 18 | args => { 19 | if (enableLogging) { 20 | const prevState = get() 21 | console.groupCollapsed( 22 | `%cZustand Action @ ${new Date().toLocaleTimeString()}`, 23 | 'font-weight: bold;' 24 | ) 25 | console.log( 26 | '%cprev state', 27 | 'color: #9E9E9E; font-weight: bold;', 28 | prevState 29 | ) 30 | console.log( 31 | '%caction ', 32 | 'color: #03A9F4; font-weight: bold;', 33 | args 34 | ) 35 | set(args) 36 | const nextState = get() 37 | console.log( 38 | '%cnext state', 39 | 'color: #4CAF50; font-weight: bold;', 40 | nextState 41 | ) 42 | console.groupEnd() 43 | } else { 44 | set(args) 45 | } 46 | }, 47 | get, 48 | api 49 | ) 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "smooth-toc-example.rev2", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "bunx --bun vite", 8 | "build": "tsc -b && bunx --bun vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@kuma-ui/core": "^1.5.8", 14 | "framer-motion": "^11.4.0", 15 | "hast": "^1.0.0", 16 | "mdast": "^3.0.0", 17 | "modern-normalize": "^3.0.0", 18 | "react": "^18.3.1", 19 | "react-dom": "^18.3.1", 20 | "rehype-react": "^8.0.0", 21 | "rehype-slug": "^6.0.0", 22 | "remark-frontmatter": "^5.0.0", 23 | "remark-gfm": "^4.0.0", 24 | "remark-parse": "^11.0.0", 25 | "remark-rehype": "^11.1.0", 26 | "unified": "^11.0.5", 27 | "unist-util-visit": "^5.0.0", 28 | "yaml": "^2.5.1", 29 | "zustand": "^4.5.5" 30 | }, 31 | "devDependencies": { 32 | "@eslint/js": "^9.9.0", 33 | "@ianvs/prettier-plugin-sort-imports": "^4.3.1", 34 | "@kuma-ui/vite": "^1.3.2", 35 | "@types/react": "^18.3.3", 36 | "@types/react-dom": "^18.3.0", 37 | "@vitejs/plugin-react": "^4.3.1", 38 | "eslint": "^9.9.0", 39 | "eslint-plugin-react-hooks": "^5.1.0-rc.0", 40 | "eslint-plugin-react-refresh": "^0.4.9", 41 | "globals": "^15.9.0", 42 | "prettier": "^3.3.3", 43 | "typescript": "^5.5.3", 44 | "typescript-eslint": "^8.0.1", 45 | "vite": "^5.4.1" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/markdown-outline-view.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Heading } from '@kuma-ui/core' 2 | import { useTocStore } from '@/stores/toc' 3 | import { MarkdownOutlineActiveSectionHighlight } from './markdown-outline-active-section-highlight' 4 | import { MarkdownOutlineItem } from './markdown-outline-item' 5 | 6 | export const MarkdownOutlineView = () => { 7 | const sections = useTocStore(state => state.sections) 8 | const minimumLevel = (sections || []).reduce( 9 | (p, c) => Math.min(p, c.level), 10 | 6 11 | ) 12 | 13 | return ( 14 | 15 | 16 | On this page 17 | 18 | 19 | 27 | 28 | 29 | {sections && 30 | sections.map(heading => ( 31 | 37 | ))} 38 | 39 | 40 | 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /src/components/markdown-outline-active-section-highlight.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@kuma-ui/core' 2 | import { motion } from 'framer-motion' 3 | import { Section } from '@/stores/toc' 4 | 5 | type Props = { 6 | sections: Section[] 7 | } 8 | 9 | export const MarkdownOutlineActiveSectionHighlight = (props: Props) => { 10 | const { sections } = props 11 | const visibleSectionIds = sections 12 | .filter(section => section.isVisible) 13 | .map(section => section.id) 14 | const elTocItems = sections.reduce((map, s) => { 15 | return { 16 | ...map, 17 | [s.id]: s.outlineItemRef?.current 18 | } 19 | }, {}) as Record 20 | 21 | const firstVisibleSectionIndex = Math.max( 22 | 0, 23 | sections.findIndex(section => section.id === visibleSectionIds[0]) 24 | ) 25 | 26 | const height: number | string = visibleSectionIds.reduce( 27 | (h, id) => h + (elTocItems[id]?.offsetHeight || 0), 28 | 0 29 | ) 30 | const top = sections 31 | .slice(0, firstVisibleSectionIndex) 32 | .reduce((t, s) => t + (elTocItems[s.id]?.offsetHeight || 0), 0) 33 | 34 | return ( 35 | 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /src/stores/content.ts: -------------------------------------------------------------------------------- 1 | import { EXIT, visit } from 'unist-util-visit' 2 | import YAML from 'yaml' 3 | import { create } from 'zustand' 4 | import { useTocStore } from './toc' 5 | import type { Root as HastRoot } from 'hast' 6 | import type { Root as MdastRoot } from 'mdast' 7 | 8 | type ContentType = React.ReactElement< 9 | unknown, 10 | string | React.JSXElementConstructor 11 | > 12 | 13 | interface ContentState { 14 | dom: ContentType | null 15 | mdast: MdastRoot | null 16 | hast: HastRoot | null 17 | title: string | null 18 | render: (markdown: string) => Promise 19 | lastError: Error | null | undefined 20 | } 21 | 22 | export const useContentStore = create(set => ({ 23 | renderId: 0, 24 | dom: null, 25 | mdast: null, 26 | hast: null, 27 | title: null, 28 | lastError: null, 29 | render: async (markdown: string) => { 30 | try { 31 | const { MarkdownRenderer } = await import('@/markdown-renderer') 32 | const renderer = new MarkdownRenderer() 33 | const { result: dom, mdast, hast } = await renderer.render(markdown) 34 | let title = '' 35 | 36 | visit(mdast, 'yaml', node => { 37 | const frontmatter = YAML.parse(node.value) 38 | title = frontmatter.title || '' 39 | return EXIT 40 | }) 41 | 42 | set({ dom, mdast, hast, title, lastError: null }) 43 | useTocStore.getState().update(mdast) 44 | } catch (e: any) { 45 | console.error(`Failed to render preview: ${e.stack}`) 46 | set({ 47 | dom: null, 48 | mdast: null, 49 | hast: null, 50 | title: null, 51 | lastError: new Error('Failed to render Markdown') 52 | }) 53 | } 54 | } 55 | })) 56 | -------------------------------------------------------------------------------- /src/hooks/use-visible-sections.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useLayoutEffect } from 'react' 2 | import { useTocStore } from '@/stores/toc' 3 | 4 | export function useVisibleSections() { 5 | const { sections, setVisibleHeadings } = useTocStore() 6 | 7 | const checkVisibleSections = useCallback(() => { 8 | const { innerHeight, scrollY } = window 9 | const newVisibleSections: string[] = [] 10 | 11 | for (let sectionIndex = 0; sectionIndex < sections.length; sectionIndex++) { 12 | const { id, headingRef } = sections[sectionIndex] 13 | 14 | if (!headingRef?.current) { 15 | continue 16 | } 17 | 18 | const top = headingRef?.current.getBoundingClientRect().top + scrollY 19 | 20 | const nextSection = sections[sectionIndex + 1] 21 | const bottom = 22 | (nextSection?.headingRef?.current?.getBoundingClientRect().top ?? 23 | Infinity) + scrollY 24 | 25 | if ( 26 | (top > scrollY && top < scrollY + innerHeight) || 27 | (bottom > scrollY && bottom < scrollY + innerHeight) || 28 | (top <= scrollY && bottom >= scrollY + innerHeight) 29 | ) { 30 | newVisibleSections.push(id) 31 | } 32 | } 33 | 34 | // check if the visible sections have changed 35 | const oldVisibleHeadings = sections.filter(s => s.isVisible).map(s => s.id) 36 | const hasChanged = oldVisibleHeadings.join() !== newVisibleSections.join() 37 | if (hasChanged) setVisibleHeadings(newVisibleSections) 38 | }, [sections, setVisibleHeadings]) 39 | 40 | useEffect(() => { 41 | window.addEventListener('scroll', checkVisibleSections, { passive: true }) 42 | window.addEventListener('resize', checkVisibleSections) 43 | 44 | return () => { 45 | window.removeEventListener('scroll', checkVisibleSections) 46 | window.removeEventListener('resize', checkVisibleSections) 47 | } 48 | }, [sections, checkVisibleSections]) 49 | 50 | useLayoutEffect(() => checkVisibleSections()) 51 | } 52 | -------------------------------------------------------------------------------- /src/markdown-renderer.ts: -------------------------------------------------------------------------------- 1 | import * as jsxRuntime from 'react/jsx-runtime' 2 | import rehype2react, { Components as JSXComponents } from 'rehype-react' 3 | import rehypeSlug from 'rehype-slug' 4 | import frontmatter from 'remark-frontmatter' 5 | import remarkGfm from 'remark-gfm' 6 | import remarkParse from 'remark-parse' 7 | import remark2rehype from 'remark-rehype' 8 | import { unified } from 'unified' 9 | import { createRehypeHeading } from '@/components/heading' 10 | 11 | const rehypeReactComponents: Partial = { 12 | h1: createRehypeHeading(1), 13 | h2: createRehypeHeading(2), 14 | h3: createRehypeHeading(3), 15 | h4: createRehypeHeading(4), 16 | h5: createRehypeHeading(5), 17 | h6: createRehypeHeading(6) 18 | } 19 | 20 | export interface BuiltInPluginOptions { 21 | rehypeSlug: boolean 22 | rehypeMetadataSection: boolean 23 | } 24 | 25 | export class MarkdownRenderer { 26 | processor: Awaited> | null = null 27 | 28 | createProcessor() { 29 | const remarkParser = unified() 30 | .use(remarkParse) 31 | .use(remarkGfm) 32 | .use(frontmatter) 33 | const rehypedRemark = remarkParser() 34 | .use(remark2rehype, { 35 | allowDangerousHtml: true 36 | }) 37 | .use(rehypeSlug) 38 | const renderer = rehypedRemark.use(rehype2react, { 39 | Fragment: jsxRuntime.Fragment as any, 40 | jsx: jsxRuntime.jsx as any, 41 | jsxs: jsxRuntime.jsxs as any, 42 | components: rehypeReactComponents 43 | }) 44 | 45 | this.processor = renderer 46 | return renderer 47 | } 48 | 49 | async getProcessor() { 50 | if (this.processor) return this.processor 51 | return this.createProcessor() 52 | } 53 | 54 | async render(markdown: string) { 55 | const processor = await this.getProcessor() 56 | const mdast = processor.parse(markdown) 57 | const hast = await processor.run(mdast) 58 | const result = processor.stringify(hast) 59 | 60 | return { 61 | result, 62 | mdast, 63 | hast 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/stores/toc.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand' 2 | import { 3 | mdastExtractHeadings, 4 | TOCHeading 5 | } from '@/utils/mdast-extract-headings' 6 | import { loggerMiddleware } from './logger' 7 | import type { Root } from 'mdast' 8 | import type { RefObject } from 'react' 9 | 10 | export type Section = TOCHeading & { 11 | headingRef: RefObject | null 12 | outlineItemRef: RefObject | null 13 | isVisible: boolean 14 | } 15 | export type TocState = { 16 | sections: Section[] 17 | update: (mdast: Root) => void 18 | registerHeading: (id: string, ref: RefObject) => void 19 | registerOutlineItem: (id: string, ref: RefObject) => void 20 | setVisibleHeadings: (ids: string[]) => void 21 | } 22 | 23 | export const useTocStore = create( 24 | loggerMiddleware((set, get) => ({ 25 | sections: [], 26 | update: (mdast: Root) => { 27 | if (mdast) { 28 | const prevSections = get().sections 29 | const sections = mdastExtractHeadings(mdast).map(h => { 30 | const prev = prevSections.find(s => s.id === h.id) 31 | return { 32 | ...h, 33 | isVisible: false, 34 | headingRef: prev ? prev.headingRef : null, 35 | outlineItemRef: prev ? prev.outlineItemRef : null 36 | } 37 | }) 38 | 39 | set({ sections }) 40 | } else { 41 | set({ sections: [] }) 42 | } 43 | }, 44 | registerHeading: (id: string, ref: RefObject) => { 45 | set(state => ({ 46 | sections: state.sections.map(s => 47 | s.id === id ? { ...s, headingRef: ref } : s 48 | ) 49 | })) 50 | }, 51 | registerOutlineItem: (id: string, ref: RefObject) => { 52 | set(state => ({ 53 | sections: state.sections.map(s => 54 | s.id === id ? { ...s, outlineItemRef: ref } : s 55 | ) 56 | })) 57 | }, 58 | setVisibleHeadings: (ids: string[]) => { 59 | set(state => ({ 60 | sections: state.sections.map(s => ({ 61 | ...s, 62 | isVisible: ids.includes(s.id) 63 | })) 64 | })) 65 | } 66 | })) 67 | ) 68 | -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/example.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: How to use PouchDB on React Native >= 0.73 3 | --- 4 | 5 | Hi, it's [Takuya](https://x.com/inkdrop_app) here. 6 | 7 | I've been updating the mobile version of [Inkdrop](https://www.inkdrop.app/), which is built with React Native. 8 | It is a simple Markdown note-taking app, which supports syncing notes with the server and offer an offline-first viewing and editing experience. 9 | To accomplish this, I've been using [PouchDB](https://pouchdb.com/), the JavaScript-based database that can sync with [Apache CouchDB](https://couchdb.apache.org/). 10 | 11 | The PouchDB community was struggling to get it to work on React Native smoothly since it doesn't provide NodeJS-compatible APIs like encoding/decoding base64 or the `crypto` module out of the box. 12 | In 2022, I shared [how to use PouchDB on React Native in this blog post](https://dev.to/craftzdog/a-performant-way-to-use-pouchdb7-on-react-native-in-2022-24ej). 13 | The technique was adequately performant by using a JSI-based SQLite driver, and polyfilling the missing modules with the native implementations respectively. 14 | 15 | Since then, the circumstances around the React Native ecosystem has been sifnificantly changed. 16 | This article is an updated version of how to use PouchDB on the latest React Native. 17 | 18 | ## Mature JSI-based libraries and the NULL char problem solved 19 | 20 | There are now JSI-based libraries that are better designed and more performant, for example: 21 | 22 | - [op-sqlite](https://github.com/OP-Engineering/op-sqlite): Fastest SQLite library for react-native by [@ospfranco](https://github.com/ospfranco) 23 | - It is approximately 8~9x faster than [react-native-quick-sqlite](https://github.com/margelo/react-native-quick-sqlite), especially on Android. 24 | - [react-native-quick-crypto](https://github.com/margelo/react-native-quick-crypto): ⚡️ A fast implementation of Node's `crypto` module written in C/C++ JSI 25 | - It internally uses my `@craftzdog/react-native-buffer` module, which improves encoding/decoding base64 performance. 26 | 27 | React Native also introduced the new architecture: TurboModules and Fabric, which themselves leverage JSI to improve the communication performance between the native and JS layers. 28 | It appears that a recent RN update, though I'm not use which commit, has solved [the `\u0000` string termination](https://github.com/facebook/react-native/issues/12731)! It means that you no longer need [a hack escaping `\u0000` chars](https://github.com/craftzdog/pouchdb-react-native/commit/228f68220fe31236f6630b71c030eef29ae6e7a8). Yay! 29 | 30 | With these new libraries and fundamental improvements, you can use PouchDB much more smoothly and straightforwardly. 31 | 32 | ## Introducing pouchdb-adapter-react-native-sqlite@4 33 | 34 | Previsouly, I made [pouchdb-adapter-react-native-sqlite](https://github.com/craftzdog/pouchdb-adapter-react-native-sqlite) to let PouchDB use SQLite on React Native. 35 | I'm excited to announce v4, which now uses [op-sqlite](https://github.com/OP-Engineering/op-sqlite). 36 | This time, I managed to make it directly call SQLite APIs and to get rid of the websql layer. 37 | 38 | Thanks to the NULL termination issue gone, attachment support is back available now. 39 | 40 | You can try an example project for a quick hands-on experience. 41 | 42 | https://github.com/craftzdog/pouchdb-adapter-react-native-sqlite/tree/master/example 43 | 44 | ## How to use 45 | 46 | Setting up the adapter is pretty easy in v4. 47 | 48 | ### Install libraries 49 | 50 | ```sh 51 | yarn add @op-engineering/op-sqlite react-native-quick-crypto @craftzdog/react-native-buffer 52 | npx pod-install 53 | ``` 54 | 55 | ### Polyfill NodeJS APIs 56 | 57 | Create a `shim.ts` file like so: 58 | 59 | ```ts 60 | import { install } from 'react-native-quick-crypto' 61 | 62 | install() 63 | ``` 64 | 65 | Configure babel to use the shim modules. First, you need to install `babel-plugin-module-resolver`. 66 | 67 | ```sh 68 | yarn add --dev babel-plugin-module-resolver 69 | ``` 70 | 71 | Then, in your `babel.config.js`, add the plugin to swap the `crypto`, `stream` and `buffer` dependencies: 72 | 73 | ```js 74 | plugins: [ 75 | [ 76 | 'module-resolver', 77 | { 78 | extensions: ['.tsx', '.ts', '.js', '.json'], 79 | alias: { 80 | crypto: 'react-native-quick-crypto', 81 | stream: 'readable-stream', 82 | buffer: '@craftzdog/react-native-buffer', 83 | }, 84 | }, 85 | ], 86 | ], 87 | ``` 88 | 89 | Then restart your bundler using `yarn start --reset-cache`. 90 | 91 | ### Install PouchDB and adapter 92 | 93 | Now it's ready to use PouchDB! 94 | 95 | ```sh 96 | yarn add pouchdb-core pouchdb-mapreduce pouchdb-replication pouchdb-adapter-http pouchdb-adapter-react-native-sqlite 97 | ``` 98 | 99 | Create `pouchdb.ts`: 100 | 101 | ```ts 102 | import HttpPouch from 'pouchdb-adapter-http' 103 | import sqliteAdapter from 'pouchdb-adapter-react-native-sqlite' 104 | import PouchDB from 'pouchdb-core' 105 | import mapreduce from 'pouchdb-mapreduce' 106 | import replication from 'pouchdb-replication' 107 | 108 | export default PouchDB.plugin(HttpPouch) 109 | .plugin(replication) 110 | .plugin(mapreduce) 111 | .plugin(sqliteAdapter) 112 | ``` 113 | 114 | ### How to use PouchDB 115 | 116 | ```ts 117 | import PouchDB from './pouchdb' 118 | 119 | const pouch = new PouchDB('mydb', { 120 | adapter: 'react-native-sqlite' 121 | }) 122 | ``` 123 | 124 | That's it! 125 | Hope you find it useful and helpful. 126 | 127 | ## Troubleshootings 128 | 129 | ### Fails to install crypto shim with `install()` on launch 130 | 131 | You amy get the following error when new arch is enabled: 132 | 133 | ``` 134 | (NOBRIDGE) ERROR Error: Failed to install react-native-quick-crypto: React Native is not running on-device. QuickCrypto can only be used when synchronous method invocations (JSI) are possible. If you are using a remote debugger (e.g. Chrome), switch to an on-device debugger (e.g. Flipper) instead. 135 | (NOBRIDGE) ERROR TypeError: Cannot read property 'install' of undefined 136 | ``` 137 | 138 | - This is a know issue: [Error: Failed to install react-native-quick-crypto: React Native is not running on-device. · Issue #333 · margelo/react-native-quick-crypto · GitHub](https://github.com/margelo/react-native-quick-crypto/issues/333) 139 | 140 | For now, you have to edit: 141 | 142 | - `lib/module/NativeQuickCrypto/NativeQuickCrypto.js` 143 | 144 | And comment them out: 145 | 146 | ``` 147 | // Check if we are running on-device (JSI) 148 | // if (global.nativeCallSyncHook == null || QuickCryptoModule.install == null) { 149 | // throw new Error('Failed to install react-native-quick-crypto: React Native is not running on-device. QuickCrypto can only be used when synchronous method invocations (JSI) are possible. If you are using a remote debugger (e.g. Chrome), switch to an on-device debugger (e.g. Flipper) instead.'); 150 | // } 151 | ``` 152 | -------------------------------------------------------------------------------- /src/tokens.css: -------------------------------------------------------------------------------- 1 | @layer tokens { 2 | :where(:root, :host) { 3 | --font-weights-thin: 100; 4 | --font-weights-extralight: 200; 5 | --font-weights-light: 300; 6 | --font-weights-normal: 400; 7 | --font-weights-medium: 500; 8 | --font-weights-semibold: 600; 9 | --font-weights-bold: 700; 10 | --font-weights-extrabold: 800; 11 | --font-weights-black: 900; 12 | --color-current: currentColor; 13 | --color-black: #000; 14 | --color-white: #fff; 15 | --color-transparent: rgb(0 0 0 / 0); 16 | --hsl-black: 0deg 0% 0%; 17 | --hsl-white: 0deg 100% 100%; 18 | --hsl-rose-50: 356deg 100% 97%; 19 | --hsl-rose-100: 356deg 100% 95%; 20 | --hsl-rose-200: 353deg 96% 90%; 21 | --hsl-rose-300: 353deg 96% 82%; 22 | --hsl-rose-400: 351deg 95% 71%; 23 | --hsl-rose-500: 350deg 89% 60%; 24 | --hsl-rose-600: 347deg 77% 50%; 25 | --hsl-rose-700: 345deg 83% 41%; 26 | --hsl-rose-800: 343deg 80% 35%; 27 | --hsl-rose-900: 342deg 75% 30%; 28 | --hsl-rose-950: 343deg 88% 16%; 29 | --hsl-pink-50: 327deg 73% 97%; 30 | --hsl-pink-100: 326deg 78% 95%; 31 | --hsl-pink-200: 326deg 85% 90%; 32 | --hsl-pink-300: 327deg 87% 82%; 33 | --hsl-pink-400: 329deg 86% 70%; 34 | --hsl-pink-500: 330deg 81% 60%; 35 | --hsl-pink-600: 333deg 71% 51%; 36 | --hsl-pink-700: 335deg 78% 42%; 37 | --hsl-pink-800: 336deg 74% 35%; 38 | --hsl-pink-900: 336deg 69% 30%; 39 | --hsl-pink-950: 336deg 84% 17%; 40 | --hsl-fuchsia-50: 289deg 100% 98%; 41 | --hsl-fuchsia-100: 287deg 100% 95%; 42 | --hsl-fuchsia-200: 288deg 96% 91%; 43 | --hsl-fuchsia-300: 291deg 93% 83%; 44 | --hsl-fuchsia-400: 292deg 91% 73%; 45 | --hsl-fuchsia-500: 292deg 84% 61%; 46 | --hsl-fuchsia-600: 293deg 69% 49%; 47 | --hsl-fuchsia-700: 295deg 72% 40%; 48 | --hsl-fuchsia-800: 295deg 70% 33%; 49 | --hsl-fuchsia-900: 297deg 64% 28%; 50 | --hsl-fuchsia-950: 297deg 90% 16%; 51 | --hsl-purple-50: 270deg 100% 98%; 52 | --hsl-purple-100: 269deg 100% 95%; 53 | --hsl-purple-200: 269deg 100% 92%; 54 | --hsl-purple-300: 269deg 97% 85%; 55 | --hsl-purple-400: 270deg 95% 75%; 56 | --hsl-purple-500: 271deg 91% 65%; 57 | --hsl-purple-600: 271deg 81% 56%; 58 | --hsl-purple-700: 272deg 72% 47%; 59 | --hsl-purple-800: 273deg 67% 39%; 60 | --hsl-purple-900: 274deg 66% 32%; 61 | --hsl-purple-950: 274deg 87% 21%; 62 | --hsl-violet-50: 250deg 100% 98%; 63 | --hsl-violet-100: 251deg 91% 95%; 64 | --hsl-violet-200: 251deg 95% 92%; 65 | --hsl-violet-300: 252deg 95% 85%; 66 | --hsl-violet-400: 255deg 92% 76%; 67 | --hsl-violet-500: 258deg 90% 66%; 68 | --hsl-violet-600: 262deg 83% 58%; 69 | --hsl-violet-700: 263deg 70% 50%; 70 | --hsl-violet-800: 263deg 69% 42%; 71 | --hsl-violet-900: 264deg 67% 35%; 72 | --hsl-violet-950: 261deg 73% 23%; 73 | --hsl-indigo-50: 226deg 100% 97%; 74 | --hsl-indigo-100: 226deg 100% 94%; 75 | --hsl-indigo-200: 228deg 96% 89%; 76 | --hsl-indigo-300: 230deg 94% 82%; 77 | --hsl-indigo-400: 234deg 89% 74%; 78 | --hsl-indigo-500: 239deg 84% 67%; 79 | --hsl-indigo-600: 243deg 75% 59%; 80 | --hsl-indigo-700: 245deg 58% 51%; 81 | --hsl-indigo-800: 244deg 55% 41%; 82 | --hsl-indigo-900: 242deg 47% 34%; 83 | --hsl-indigo-950: 244deg 47% 20%; 84 | --hsl-blue-50: 214deg 100% 97%; 85 | --hsl-blue-100: 214deg 95% 93%; 86 | --hsl-blue-200: 213deg 97% 87%; 87 | --hsl-blue-300: 212deg 96% 78%; 88 | --hsl-blue-400: 213deg 94% 68%; 89 | --hsl-blue-500: 217deg 91% 60%; 90 | --hsl-blue-600: 221deg 83% 53%; 91 | --hsl-blue-700: 224deg 76% 48%; 92 | --hsl-blue-800: 226deg 71% 40%; 93 | --hsl-blue-900: 224deg 64% 33%; 94 | --hsl-blue-950: 226deg 57% 21%; 95 | --hsl-sky-50: 204deg 100% 97%; 96 | --hsl-sky-100: 204deg 94% 94%; 97 | --hsl-sky-200: 201deg 94% 86%; 98 | --hsl-sky-300: 199deg 95% 74%; 99 | --hsl-sky-400: 198deg 93% 60%; 100 | --hsl-sky-500: 199deg 89% 48%; 101 | --hsl-sky-600: 200deg 98% 39%; 102 | --hsl-sky-700: 201deg 96% 32%; 103 | --hsl-sky-800: 201deg 90% 27%; 104 | --hsl-sky-900: 202deg 80% 24%; 105 | --hsl-sky-950: 204deg 80% 16%; 106 | --hsl-cyan-50: 183deg 100% 96%; 107 | --hsl-cyan-100: 185deg 96% 90%; 108 | --hsl-cyan-200: 186deg 94% 82%; 109 | --hsl-cyan-300: 187deg 92% 69%; 110 | --hsl-cyan-400: 188deg 86% 53%; 111 | --hsl-cyan-500: 189deg 94% 43%; 112 | --hsl-cyan-600: 192deg 91% 36%; 113 | --hsl-cyan-700: 193deg 82% 31%; 114 | --hsl-cyan-800: 194deg 70% 27%; 115 | --hsl-cyan-900: 196deg 64% 24%; 116 | --hsl-cyan-950: 197deg 79% 15%; 117 | --hsl-teal-50: 166deg 76% 97%; 118 | --hsl-teal-100: 167deg 85% 89%; 119 | --hsl-teal-200: 168deg 84% 78%; 120 | --hsl-teal-300: 171deg 77% 64%; 121 | --hsl-teal-400: 172deg 66% 50%; 122 | --hsl-teal-500: 173deg 80% 40%; 123 | --hsl-teal-600: 175deg 84% 32%; 124 | --hsl-teal-700: 175deg 77% 26%; 125 | --hsl-teal-800: 176deg 69% 22%; 126 | --hsl-teal-900: 176deg 61% 19%; 127 | --hsl-teal-950: 179deg 84% 10%; 128 | --hsl-emerald-50: 152deg 81% 96%; 129 | --hsl-emerald-100: 149deg 80% 90%; 130 | --hsl-emerald-200: 152deg 76% 80%; 131 | --hsl-emerald-300: 156deg 72% 67%; 132 | --hsl-emerald-400: 158deg 64% 52%; 133 | --hsl-emerald-500: 160deg 84% 39%; 134 | --hsl-emerald-600: 161deg 94% 30%; 135 | --hsl-emerald-700: 163deg 94% 24%; 136 | --hsl-emerald-800: 163deg 88% 20%; 137 | --hsl-emerald-900: 164deg 86% 16%; 138 | --hsl-emerald-950: 166deg 91% 9%; 139 | --hsl-green-50: 138deg 76% 97%; 140 | --hsl-green-100: 141deg 84% 93%; 141 | --hsl-green-200: 141deg 79% 85%; 142 | --hsl-green-300: 142deg 77% 73%; 143 | --hsl-green-400: 142deg 69% 58%; 144 | --hsl-green-500: 142deg 71% 45%; 145 | --hsl-green-600: 142deg 76% 36%; 146 | --hsl-green-700: 142deg 72% 29%; 147 | --hsl-green-800: 143deg 64% 24%; 148 | --hsl-green-900: 144deg 61% 20%; 149 | --hsl-green-950: 145deg 80% 10%; 150 | --hsl-lime-50: 78deg 92% 95%; 151 | --hsl-lime-100: 80deg 89% 89%; 152 | --hsl-lime-200: 81deg 88% 80%; 153 | --hsl-lime-300: 82deg 85% 67%; 154 | --hsl-lime-400: 83deg 78% 55%; 155 | --hsl-lime-500: 84deg 81% 44%; 156 | --hsl-lime-600: 85deg 85% 35%; 157 | --hsl-lime-700: 86deg 78% 27%; 158 | --hsl-lime-800: 86deg 69% 23%; 159 | --hsl-lime-900: 88deg 61% 20%; 160 | --hsl-lime-950: 89deg 80% 10%; 161 | --hsl-yellow-50: 55deg 92% 95%; 162 | --hsl-yellow-100: 55deg 97% 88%; 163 | --hsl-yellow-200: 53deg 98% 77%; 164 | --hsl-yellow-300: 50deg 98% 64%; 165 | --hsl-yellow-400: 48deg 96% 53%; 166 | --hsl-yellow-500: 45deg 93% 47%; 167 | --hsl-yellow-600: 41deg 96% 40%; 168 | --hsl-yellow-700: 35deg 92% 33%; 169 | --hsl-yellow-800: 32deg 81% 29%; 170 | --hsl-yellow-900: 28deg 73% 26%; 171 | --hsl-yellow-950: 26deg 83% 14%; 172 | --hsl-amber-50: 48deg 100% 96%; 173 | --hsl-amber-100: 48deg 96% 89%; 174 | --hsl-amber-200: 48deg 97% 77%; 175 | --hsl-amber-300: 46deg 97% 65%; 176 | --hsl-amber-400: 43deg 96% 56%; 177 | --hsl-amber-500: 38deg 92% 50%; 178 | --hsl-amber-600: 32deg 95% 44%; 179 | --hsl-amber-700: 26deg 90% 37%; 180 | --hsl-amber-800: 23deg 83% 31%; 181 | --hsl-amber-900: 22deg 78% 26%; 182 | --hsl-amber-950: 21deg 92% 14%; 183 | --hsl-orange-50: 33deg 100% 96%; 184 | --hsl-orange-100: 34deg 100% 92%; 185 | --hsl-orange-200: 32deg 98% 83%; 186 | --hsl-orange-300: 31deg 97% 72%; 187 | --hsl-orange-400: 27deg 96% 61%; 188 | --hsl-orange-500: 25deg 95% 53%; 189 | --hsl-orange-600: 21deg 90% 48%; 190 | --hsl-orange-700: 17deg 88% 40%; 191 | --hsl-orange-800: 15deg 79% 34%; 192 | --hsl-orange-900: 15deg 75% 28%; 193 | --hsl-orange-950: 13deg 81% 15%; 194 | --hsl-red-50: 0deg 86% 97%; 195 | --hsl-red-100: 0deg 93% 94%; 196 | --hsl-red-200: 0deg 96% 89%; 197 | --hsl-red-300: 0deg 94% 82%; 198 | --hsl-red-400: 0deg 91% 71%; 199 | --hsl-red-500: 0deg 84% 60%; 200 | --hsl-red-600: 0deg 72% 51%; 201 | --hsl-red-700: 0deg 74% 42%; 202 | --hsl-red-800: 0deg 70% 35%; 203 | --hsl-red-900: 0deg 63% 31%; 204 | --hsl-red-950: 0deg 75% 15%; 205 | --hsl-neutral-50: 0deg 0% 98%; 206 | --hsl-neutral-100: 0deg 0% 96%; 207 | --hsl-neutral-200: 0deg 0% 90%; 208 | --hsl-neutral-300: 0deg 0% 83%; 209 | --hsl-neutral-400: 0deg 0% 64%; 210 | --hsl-neutral-500: 0deg 0% 45%; 211 | --hsl-neutral-600: 0deg 0% 32%; 212 | --hsl-neutral-700: 0deg 0% 25%; 213 | --hsl-neutral-800: 0deg 0% 15%; 214 | --hsl-neutral-900: 0deg 0% 9%; 215 | --hsl-neutral-950: 0deg 0% 4%; 216 | --hsl-stone-50: 60deg 9% 98%; 217 | --hsl-stone-100: 60deg 5% 96%; 218 | --hsl-stone-200: 20deg 6% 90%; 219 | --hsl-stone-300: 24deg 6% 83%; 220 | --hsl-stone-400: 24deg 5% 64%; 221 | --hsl-stone-500: 25deg 5% 45%; 222 | --hsl-stone-600: 33deg 5% 32%; 223 | --hsl-stone-700: 30deg 6% 25%; 224 | --hsl-stone-800: 12deg 6% 15%; 225 | --hsl-stone-900: 24deg 10% 10%; 226 | --hsl-stone-950: 20deg 14% 4%; 227 | --hsl-zinc-50: 0deg 0% 98%; 228 | --hsl-zinc-100: 240deg 5% 96%; 229 | --hsl-zinc-200: 240deg 6% 90%; 230 | --hsl-zinc-300: 240deg 5% 84%; 231 | --hsl-zinc-400: 240deg 5% 65%; 232 | --hsl-zinc-500: 240deg 4% 46%; 233 | --hsl-zinc-600: 240deg 5% 34%; 234 | --hsl-zinc-700: 240deg 5% 26%; 235 | --hsl-zinc-800: 240deg 4% 16%; 236 | --hsl-zinc-900: 240deg 6% 10%; 237 | --hsl-zinc-950: 240deg 10% 4%; 238 | --hsl-gray-50: 210deg 20% 98%; 239 | --hsl-gray-100: 220deg 14% 96%; 240 | --hsl-gray-200: 220deg 13% 91%; 241 | --hsl-gray-300: 216deg 12% 84%; 242 | --hsl-gray-400: 218deg 11% 65%; 243 | --hsl-gray-500: 220deg 9% 46%; 244 | --hsl-gray-600: 215deg 14% 34%; 245 | --hsl-gray-700: 217deg 19% 27%; 246 | --hsl-gray-800: 215deg 28% 17%; 247 | --hsl-gray-900: 221deg 39% 11%; 248 | --hsl-gray-950: 224deg 71% 4%; 249 | --hsl-slate-50: 210deg 40% 98%; 250 | --hsl-slate-100: 210deg 40% 96%; 251 | --hsl-slate-200: 214deg 32% 91%; 252 | --hsl-slate-300: 213deg 27% 84%; 253 | --hsl-slate-400: 215deg 20% 65%; 254 | --hsl-slate-500: 215deg 16% 47%; 255 | --hsl-slate-600: 215deg 19% 35%; 256 | --hsl-slate-700: 215deg 25% 27%; 257 | --hsl-slate-800: 217deg 33% 17%; 258 | --hsl-slate-900: 222deg 47% 11%; 259 | --hsl-slate-950: 229deg 84% 5%; 260 | 261 | --color-rose-50: hsl(var(--hsl-rose-50)); 262 | --color-rose-100: hsl(var(--hsl-rose-100)); 263 | --color-rose-200: hsl(var(--hsl-rose-200)); 264 | --color-rose-300: hsl(var(--hsl-rose-300)); 265 | --color-rose-400: hsl(var(--hsl-rose-400)); 266 | --color-rose-500: hsl(var(--hsl-rose-500)); 267 | --color-rose-600: hsl(var(--hsl-rose-600)); 268 | --color-rose-700: hsl(var(--hsl-rose-700)); 269 | --color-rose-800: hsl(var(--hsl-rose-800)); 270 | --color-rose-900: hsl(var(--hsl-rose-900)); 271 | --color-rose-950: hsl(var(--hsl-rose-950)); 272 | --color-pink-50: hsl(var(--hsl-pink-50)); 273 | --color-pink-100: hsl(var(--hsl-pink-100)); 274 | --color-pink-200: hsl(var(--hsl-pink-200)); 275 | --color-pink-300: hsl(var(--hsl-pink-300)); 276 | --color-pink-400: hsl(var(--hsl-pink-400)); 277 | --color-pink-500: hsl(var(--hsl-pink-500)); 278 | --color-pink-600: hsl(var(--hsl-pink-600)); 279 | --color-pink-700: hsl(var(--hsl-pink-700)); 280 | --color-pink-800: hsl(var(--hsl-pink-800)); 281 | --color-pink-900: hsl(var(--hsl-pink-900)); 282 | --color-pink-950: hsl(var(--hsl-pink-950)); 283 | --color-fuchsia-50: hsl(var(--hsl-fuchsia-50)); 284 | --color-fuchsia-100: hsl(var(--hsl-fuchsia-100)); 285 | --color-fuchsia-200: hsl(var(--hsl-fuchsia-200)); 286 | --color-fuchsia-300: hsl(var(--hsl-fuchsia-300)); 287 | --color-fuchsia-400: hsl(var(--hsl-fuchsia-400)); 288 | --color-fuchsia-500: hsl(var(--hsl-fuchsia-500)); 289 | --color-fuchsia-600: hsl(var(--hsl-fuchsia-600)); 290 | --color-fuchsia-700: hsl(var(--hsl-fuchsia-700)); 291 | --color-fuchsia-800: hsl(var(--hsl-fuchsia-800)); 292 | --color-fuchsia-900: hsl(var(--hsl-fuchsia-900)); 293 | --color-fuchsia-950: hsl(var(--hsl-fuchsia-950)); 294 | --color-purple-50: hsl(var(--hsl-purple-50)); 295 | --color-purple-100: hsl(var(--hsl-purple-100)); 296 | --color-purple-200: hsl(var(--hsl-purple-200)); 297 | --color-purple-300: hsl(var(--hsl-purple-300)); 298 | --color-purple-400: hsl(var(--hsl-purple-400)); 299 | --color-purple-500: hsl(var(--hsl-purple-500)); 300 | --color-purple-600: hsl(var(--hsl-purple-600)); 301 | --color-purple-700: hsl(var(--hsl-purple-700)); 302 | --color-purple-800: hsl(var(--hsl-purple-800)); 303 | --color-purple-900: hsl(var(--hsl-purple-900)); 304 | --color-purple-950: hsl(var(--hsl-purple-950)); 305 | --color-violet-50: hsl(var(--hsl-violet-50)); 306 | --color-violet-100: hsl(var(--hsl-violet-100)); 307 | --color-violet-200: hsl(var(--hsl-violet-200)); 308 | --color-violet-300: hsl(var(--hsl-violet-300)); 309 | --color-violet-400: hsl(var(--hsl-violet-400)); 310 | --color-violet-500: hsl(var(--hsl-violet-500)); 311 | --color-violet-600: hsl(var(--hsl-violet-600)); 312 | --color-violet-700: hsl(var(--hsl-violet-700)); 313 | --color-violet-800: hsl(var(--hsl-violet-800)); 314 | --color-violet-900: hsl(var(--hsl-violet-900)); 315 | --color-violet-950: hsl(var(--hsl-violet-950)); 316 | --color-indigo-50: hsl(var(--hsl-indigo-50)); 317 | --color-indigo-100: hsl(var(--hsl-indigo-100)); 318 | --color-indigo-200: hsl(var(--hsl-indigo-200)); 319 | --color-indigo-300: hsl(var(--hsl-indigo-300)); 320 | --color-indigo-400: hsl(var(--hsl-indigo-400)); 321 | --color-indigo-500: hsl(var(--hsl-indigo-500)); 322 | --color-indigo-600: hsl(var(--hsl-indigo-600)); 323 | --color-indigo-700: hsl(var(--hsl-indigo-700)); 324 | --color-indigo-800: hsl(var(--hsl-indigo-800)); 325 | --color-indigo-900: hsl(var(--hsl-indigo-900)); 326 | --color-indigo-950: hsl(var(--hsl-indigo-950)); 327 | --color-blue-50: hsl(var(--hsl-blue-50)); 328 | --color-blue-100: hsl(var(--hsl-blue-100)); 329 | --color-blue-200: hsl(var(--hsl-blue-200)); 330 | --color-blue-300: hsl(var(--hsl-blue-300)); 331 | --color-blue-400: hsl(var(--hsl-blue-400)); 332 | --color-blue-500: hsl(var(--hsl-blue-500)); 333 | --color-blue-600: hsl(var(--hsl-blue-600)); 334 | --color-blue-700: hsl(var(--hsl-blue-700)); 335 | --color-blue-800: hsl(var(--hsl-blue-800)); 336 | --color-blue-900: hsl(var(--hsl-blue-900)); 337 | --color-blue-950: hsl(var(--hsl-blue-950)); 338 | --color-sky-50: hsl(var(--hsl-sky-50)); 339 | --color-sky-100: hsl(var(--hsl-sky-100)); 340 | --color-sky-200: hsl(var(--hsl-sky-200)); 341 | --color-sky-300: hsl(var(--hsl-sky-300)); 342 | --color-sky-400: hsl(var(--hsl-sky-400)); 343 | --color-sky-500: hsl(var(--hsl-sky-500)); 344 | --color-sky-600: hsl(var(--hsl-sky-600)); 345 | --color-sky-700: hsl(var(--hsl-sky-700)); 346 | --color-sky-800: hsl(var(--hsl-sky-800)); 347 | --color-sky-900: hsl(var(--hsl-sky-900)); 348 | --color-sky-950: hsl(var(--hsl-sky-950)); 349 | --color-cyan-50: hsl(var(--hsl-cyan-50)); 350 | --color-cyan-100: hsl(var(--hsl-cyan-100)); 351 | --color-cyan-200: hsl(var(--hsl-cyan-200)); 352 | --color-cyan-300: hsl(var(--hsl-cyan-300)); 353 | --color-cyan-400: hsl(var(--hsl-cyan-400)); 354 | --color-cyan-500: hsl(var(--hsl-cyan-500)); 355 | --color-cyan-600: hsl(var(--hsl-cyan-600)); 356 | --color-cyan-700: hsl(var(--hsl-cyan-700)); 357 | --color-cyan-800: hsl(var(--hsl-cyan-800)); 358 | --color-cyan-900: hsl(var(--hsl-cyan-900)); 359 | --color-cyan-950: hsl(var(--hsl-cyan-950)); 360 | --color-teal-50: hsl(var(--hsl-teal-50)); 361 | --color-teal-100: hsl(var(--hsl-teal-100)); 362 | --color-teal-200: hsl(var(--hsl-teal-200)); 363 | --color-teal-300: hsl(var(--hsl-teal-300)); 364 | --color-teal-400: hsl(var(--hsl-teal-400)); 365 | --color-teal-500: hsl(var(--hsl-teal-500)); 366 | --color-teal-600: hsl(var(--hsl-teal-600)); 367 | --color-teal-700: hsl(var(--hsl-teal-700)); 368 | --color-teal-800: hsl(var(--hsl-teal-800)); 369 | --color-teal-900: hsl(var(--hsl-teal-900)); 370 | --color-teal-950: hsl(var(--hsl-teal-950)); 371 | --color-emerald-50: hsl(var(--hsl-emerald-50)); 372 | --color-emerald-100: hsl(var(--hsl-emerald-100)); 373 | --color-emerald-200: hsl(var(--hsl-emerald-200)); 374 | --color-emerald-300: hsl(var(--hsl-emerald-300)); 375 | --color-emerald-400: hsl(var(--hsl-emerald-400)); 376 | --color-emerald-500: hsl(var(--hsl-emerald-500)); 377 | --color-emerald-600: hsl(var(--hsl-emerald-600)); 378 | --color-emerald-700: hsl(var(--hsl-emerald-700)); 379 | --color-emerald-800: hsl(var(--hsl-emerald-800)); 380 | --color-emerald-900: hsl(var(--hsl-emerald-900)); 381 | --color-emerald-950: hsl(var(--hsl-emerald-950)); 382 | --color-green-50: hsl(var(--hsl-green-50)); 383 | --color-green-100: hsl(var(--hsl-green-100)); 384 | --color-green-200: hsl(var(--hsl-green-200)); 385 | --color-green-300: hsl(var(--hsl-green-300)); 386 | --color-green-400: hsl(var(--hsl-green-400)); 387 | --color-green-500: hsl(var(--hsl-green-500)); 388 | --color-green-600: hsl(var(--hsl-green-600)); 389 | --color-green-700: hsl(var(--hsl-green-700)); 390 | --color-green-800: hsl(var(--hsl-green-800)); 391 | --color-green-900: hsl(var(--hsl-green-900)); 392 | --color-green-950: hsl(var(--hsl-green-950)); 393 | --color-lime-50: hsl(var(--hsl-lime-50)); 394 | --color-lime-100: hsl(var(--hsl-lime-100)); 395 | --color-lime-200: hsl(var(--hsl-lime-200)); 396 | --color-lime-300: hsl(var(--hsl-lime-300)); 397 | --color-lime-400: hsl(var(--hsl-lime-400)); 398 | --color-lime-500: hsl(var(--hsl-lime-500)); 399 | --color-lime-600: hsl(var(--hsl-lime-600)); 400 | --color-lime-700: hsl(var(--hsl-lime-700)); 401 | --color-lime-800: hsl(var(--hsl-lime-800)); 402 | --color-lime-900: hsl(var(--hsl-lime-900)); 403 | --color-lime-950: hsl(var(--hsl-lime-950)); 404 | --color-yellow-50: hsl(var(--hsl-yellow-50)); 405 | --color-yellow-100: hsl(var(--hsl-yellow-100)); 406 | --color-yellow-200: hsl(var(--hsl-yellow-200)); 407 | --color-yellow-300: hsl(var(--hsl-yellow-300)); 408 | --color-yellow-400: hsl(var(--hsl-yellow-400)); 409 | --color-yellow-500: hsl(var(--hsl-yellow-500)); 410 | --color-yellow-600: hsl(var(--hsl-yellow-600)); 411 | --color-yellow-700: hsl(var(--hsl-yellow-700)); 412 | --color-yellow-800: hsl(var(--hsl-yellow-800)); 413 | --color-yellow-900: hsl(var(--hsl-yellow-900)); 414 | --color-yellow-950: hsl(var(--hsl-yellow-950)); 415 | --color-amber-50: hsl(var(--hsl-amber-50)); 416 | --color-amber-100: hsl(var(--hsl-amber-100)); 417 | --color-amber-200: hsl(var(--hsl-amber-200)); 418 | --color-amber-300: hsl(var(--hsl-amber-300)); 419 | --color-amber-400: hsl(var(--hsl-amber-400)); 420 | --color-amber-500: hsl(var(--hsl-amber-500)); 421 | --color-amber-600: hsl(var(--hsl-amber-600)); 422 | --color-amber-700: hsl(var(--hsl-amber-700)); 423 | --color-amber-800: hsl(var(--hsl-amber-800)); 424 | --color-amber-900: hsl(var(--hsl-amber-900)); 425 | --color-amber-950: hsl(var(--hsl-amber-950)); 426 | --color-orange-50: hsl(var(--hsl-orange-50)); 427 | --color-orange-100: hsl(var(--hsl-orange-100)); 428 | --color-orange-200: hsl(var(--hsl-orange-200)); 429 | --color-orange-300: hsl(var(--hsl-orange-300)); 430 | --color-orange-400: hsl(var(--hsl-orange-400)); 431 | --color-orange-500: hsl(var(--hsl-orange-500)); 432 | --color-orange-600: hsl(var(--hsl-orange-600)); 433 | --color-orange-700: hsl(var(--hsl-orange-700)); 434 | --color-orange-800: hsl(var(--hsl-orange-800)); 435 | --color-orange-900: hsl(var(--hsl-orange-900)); 436 | --color-orange-950: hsl(var(--hsl-orange-950)); 437 | --color-red-50: hsl(var(--hsl-red-50)); 438 | --color-red-100: hsl(var(--hsl-red-100)); 439 | --color-red-200: hsl(var(--hsl-red-200)); 440 | --color-red-300: hsl(var(--hsl-red-300)); 441 | --color-red-400: hsl(var(--hsl-red-400)); 442 | --color-red-500: hsl(var(--hsl-red-500)); 443 | --color-red-600: hsl(var(--hsl-red-600)); 444 | --color-red-700: hsl(var(--hsl-red-700)); 445 | --color-red-800: hsl(var(--hsl-red-800)); 446 | --color-red-900: hsl(var(--hsl-red-900)); 447 | --color-red-950: hsl(var(--hsl-red-950)); 448 | --color-neutral-50: hsl(var(--hsl-neutral-50)); 449 | --color-neutral-100: hsl(var(--hsl-neutral-100)); 450 | --color-neutral-200: hsl(var(--hsl-neutral-200)); 451 | --color-neutral-300: hsl(var(--hsl-neutral-300)); 452 | --color-neutral-400: hsl(var(--hsl-neutral-400)); 453 | --color-neutral-500: hsl(var(--hsl-neutral-500)); 454 | --color-neutral-600: hsl(var(--hsl-neutral-600)); 455 | --color-neutral-700: hsl(var(--hsl-neutral-700)); 456 | --color-neutral-800: hsl(var(--hsl-neutral-800)); 457 | --color-neutral-900: hsl(var(--hsl-neutral-900)); 458 | --color-neutral-950: hsl(var(--hsl-neutral-950)); 459 | --color-stone-50: hsl(var(--hsl-stone-50)); 460 | --color-stone-100: hsl(var(--hsl-stone-100)); 461 | --color-stone-200: hsl(var(--hsl-stone-200)); 462 | --color-stone-300: hsl(var(--hsl-stone-300)); 463 | --color-stone-400: hsl(var(--hsl-stone-400)); 464 | --color-stone-500: hsl(var(--hsl-stone-500)); 465 | --color-stone-600: hsl(var(--hsl-stone-600)); 466 | --color-stone-700: hsl(var(--hsl-stone-700)); 467 | --color-stone-800: hsl(var(--hsl-stone-800)); 468 | --color-stone-900: hsl(var(--hsl-stone-900)); 469 | --color-stone-950: hsl(var(--hsl-stone-950)); 470 | --color-zinc-50: hsl(var(--hsl-zinc-50)); 471 | --color-zinc-100: hsl(var(--hsl-zinc-100)); 472 | --color-zinc-200: hsl(var(--hsl-zinc-200)); 473 | --color-zinc-300: hsl(var(--hsl-zinc-300)); 474 | --color-zinc-400: hsl(var(--hsl-zinc-400)); 475 | --color-zinc-500: hsl(var(--hsl-zinc-500)); 476 | --color-zinc-600: hsl(var(--hsl-zinc-600)); 477 | --color-zinc-700: hsl(var(--hsl-zinc-700)); 478 | --color-zinc-800: hsl(var(--hsl-zinc-800)); 479 | --color-zinc-900: hsl(var(--hsl-zinc-900)); 480 | --color-zinc-950: hsl(var(--hsl-zinc-950)); 481 | --color-gray-50: hsl(var(--hsl-gray-50)); 482 | --color-gray-100: hsl(var(--hsl-gray-100)); 483 | --color-gray-200: hsl(var(--hsl-gray-200)); 484 | --color-gray-300: hsl(var(--hsl-gray-300)); 485 | --color-gray-400: hsl(var(--hsl-gray-400)); 486 | --color-gray-500: hsl(var(--hsl-gray-500)); 487 | --color-gray-600: hsl(var(--hsl-gray-600)); 488 | --color-gray-700: hsl(var(--hsl-gray-700)); 489 | --color-gray-800: hsl(var(--hsl-gray-800)); 490 | --color-gray-900: hsl(var(--hsl-gray-900)); 491 | --color-gray-950: hsl(var(--hsl-gray-950)); 492 | --color-slate-50: hsl(var(--hsl-slate-50)); 493 | --color-slate-100: hsl(var(--hsl-slate-100)); 494 | --color-slate-200: hsl(var(--hsl-slate-200)); 495 | --color-slate-300: hsl(var(--hsl-slate-300)); 496 | --color-slate-400: hsl(var(--hsl-slate-400)); 497 | --color-slate-500: hsl(var(--hsl-slate-500)); 498 | --color-slate-600: hsl(var(--hsl-slate-600)); 499 | --color-slate-700: hsl(var(--hsl-slate-700)); 500 | --color-slate-800: hsl(var(--hsl-slate-800)); 501 | --color-slate-900: hsl(var(--hsl-slate-900)); 502 | --color-slate-950: hsl(var(--hsl-slate-950)); 503 | } 504 | } 505 | --------------------------------------------------------------------------------