├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── README.md ├── next.config.mjs ├── outline.md ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── src └── app │ ├── AutoScroller.tsx │ ├── components │ ├── alert.tsx │ ├── avatar.tsx │ ├── badge.tsx │ ├── button.tsx │ ├── checkbox.tsx │ ├── description-list.tsx │ ├── dialog.tsx │ ├── divider.tsx │ ├── dropdown.tsx │ ├── fieldset.tsx │ ├── heading.tsx │ ├── input.tsx │ ├── link.tsx │ ├── listbox.tsx │ ├── navbar.tsx │ ├── pagination.tsx │ ├── radio.tsx │ ├── select.tsx │ ├── sidebar-layout.tsx │ ├── sidebar.tsx │ ├── stacked-layout.tsx │ ├── switch.tsx │ ├── table.tsx │ ├── text.tsx │ └── textarea.tsx │ ├── favicon.ico │ ├── fonts │ ├── GeistMonoVF.woff │ └── GeistVF.woff │ ├── globals.css │ ├── layout.tsx │ ├── page.tsx │ └── useAnimatedText.ts ├── tailwind.config.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "next/typescript"], 3 | "rules": { 4 | "prefer-const": "off" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | {"plugins": ["prettier-plugin-tailwindcss"]} 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. 37 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /outline.md: -------------------------------------------------------------------------------- 1 | ## 🟢 STEP 2 | 3 | ```tsx 4 | let animatedText = useAnimatedText(text); 5 | 6 | function useAnimatedText(text: string) { 7 | return text; 8 | } 9 | 10 | // And default to hello world, to have something to play with 11 | let [text, setText] = useState("Hello world"); 12 | ``` 13 | 14 | ## 🟢 STEP 15 | 16 | How to animate? 17 | 18 | ```tsx 19 | function useAnimatedText(text: string) { 20 | let [cursor, setCursor] = useState(0); 21 | 22 | return text.slice(0, 4); 23 | } 24 | ``` 25 | 26 | ## 🟢 STEP 27 | 28 | ```tsx 29 | function useAnimatedText(text: string) { 30 | let [cursor, setCursor] = useState(0); 31 | 32 | useEffect(() => { 33 | animate(0, text.length, { 34 | onUpdate(latest) { 35 | console.log(latest); 36 | }, 37 | }); 38 | }, []); 39 | 40 | return text.slice(0, cursor); 41 | } 42 | ``` 43 | 44 | Strict mode! 45 | 46 | ```tsx 47 | useEffect(() => { 48 | console.log("effect"); 49 | let controls = animate(0, text.length, { 50 | onUpdate(latest) { 51 | console.log(latest); 52 | }, 53 | }); 54 | 55 | return () => controls.stop(); 56 | }, []); 57 | ``` 58 | 59 | Now in business. 60 | 61 | ## 🟢 STEP 62 | 63 | Now let's update our state. Let's math.floor it. 64 | 65 | ```tsx 66 | function useAnimatedText(text: string) { 67 | let [cursor, setCursor] = useState(8); 68 | 69 | useEffect(() => { 70 | let controls = animate(0, text.length, { 71 | duration: 2, 72 | // Play with this 73 | ease: "linear", 74 | onUpdate(latest) { 75 | setCursor(Math.floor(latest)); 76 | }, 77 | }); 78 | 79 | return () => controls.stop(); 80 | }, [text.length]); 81 | 82 | return text.slice(0, cursor); 83 | } 84 | ``` 85 | 86 | ## 🟢 STEP 87 | 88 | Looks good! Let's go back to empty string and try out streaming. 89 | 90 | Somethings happening... slow things down 91 | 92 | ```tsx 93 | let delay = 2000; 94 | let characters = 50; 95 | ``` 96 | 97 | Starting from 0 each time. Need some memory... 98 | 99 | MotionValue! 100 | 101 | ```tsx 102 | function useAnimatedText(text: string) { 103 | let animatedCursor = useMotionValue(0); 104 | let [cursor, setCursor] = useState(8); 105 | 106 | useEffect(() => { 107 | let controls = animate(animatedCursor, text.length, { 108 | duration: 2, 109 | ease: "linear", 110 | onUpdate(latest) { 111 | setCursor(Math.floor(latest)); 112 | }, 113 | }); 114 | 115 | return () => controls.stop(); 116 | }, [animatedCursor, text.length]); 117 | 118 | return text.slice(0, cursor); 119 | } 120 | ``` 121 | 122 | So cool, it "catches up". 123 | 124 | ```tsx 125 | let delay = 250; 126 | let characters = 50; 127 | 128 | duration: 8, 129 | ease: "easeOut", 130 | ``` 131 | 132 | ## 🟢 STEP 133 | 134 | Ok, let's try Reset. 135 | 136 | Seems to not work. Well, we have two pieces of state: animatedCursor and cursor. 137 | 138 | Let's add a log to render and see what's happening: 139 | 140 | ```tsx 141 | console.log({ cursor }); 142 | ``` 143 | 144 | So, animating from 100 back to 0. 145 | 146 | Memory is good when we're appending. But not when we have new text. 147 | 148 | How do we know when we have new text? New text doesn't startWiht prev text. 149 | 150 | Need new state variable for prevText, and another for whether it's the same text. 151 | 152 | ```tsx 153 | let [prevText, setPrevText] = useState(text); 154 | let [isSameText, setIsSameText] = useState(false); 155 | 156 | if (prevText !== text) { 157 | setPrevText(text); 158 | setIsSameText(text.startsWith(prevText)); 159 | } 160 | ``` 161 | 162 | Now in the beginning of our effect, we can use it to reset our animated cursor in the case where the text is new: 163 | 164 | ```tsx 165 | useEffect(() => { 166 | if (!isSameText) { 167 | animatedCursor.jump(0); 168 | } 169 | 170 | // ... 171 | }); 172 | ``` 173 | 174 | Now let's look at our logs. 175 | 176 | Boom. It works! 177 | 178 | ## 🟢 STEP 179 | 180 | Delimiter maybe. 181 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "2024-10-20-use-animated-text-hook", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@headlessui/react": "^2.1.8", 13 | "@heroicons/react": "^2.1.5", 14 | "@radix-ui/react-slider": "^1.2.1", 15 | "@uidotdev/usehooks": "^2.4.1", 16 | "clsx": "^2.1.1", 17 | "framer-motion": "^11.7.0", 18 | "next": "14.2.13", 19 | "react": "^18", 20 | "react-dom": "^18" 21 | }, 22 | "devDependencies": { 23 | "@types/node": "^20", 24 | "@types/react": "^18", 25 | "@types/react-dom": "^18", 26 | "eslint": "^8", 27 | "eslint-config-next": "14.2.13", 28 | "postcss": "^8", 29 | "prettier": "^3.3.3", 30 | "prettier-plugin-tailwindcss": "^0.6.8", 31 | "tailwindcss": "^3.4.1", 32 | "typescript": "^5" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /src/app/AutoScroller.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { animate, AnimationPlaybackControls } from "framer-motion"; 4 | import { 5 | ComponentProps, 6 | createContext, 7 | Dispatch, 8 | MutableRefObject, 9 | RefObject, 10 | SetStateAction, 11 | useContext, 12 | useEffect, 13 | useRef, 14 | useState, 15 | } from "react"; 16 | 17 | let AutoScrollerContext = createContext<{ 18 | isAutoscrolling: boolean; 19 | setIsAutoscrolling: Dispatch>; 20 | scrollContainerRef: RefObject; 21 | animationControlsRef: MutableRefObject; 22 | }>({ 23 | isAutoscrolling: true, 24 | setIsAutoscrolling: () => {}, 25 | scrollContainerRef: { current: null }, 26 | animationControlsRef: { current: undefined }, 27 | }); 28 | 29 | export function AutoScroller({ children, ...rest }: ComponentProps<"div">) { 30 | let scrollContainerRef = useRef(null); 31 | let animationControlsRef = useRef(); 32 | let [isAutoscrolling, setIsAutoscrolling] = useState(true); 33 | 34 | return ( 35 | 43 |
{children}
44 |
45 | ); 46 | } 47 | 48 | export function AutoScrollerContent({ 49 | children, 50 | ...rest 51 | }: ComponentProps<"div">) { 52 | let { 53 | isAutoscrolling, 54 | setIsAutoscrolling, 55 | scrollContainerRef, 56 | animationControlsRef, 57 | } = useContext(AutoScrollerContext); 58 | 59 | let contentRef = useRef(null); 60 | let whiteSpaceRef = useRef(null); 61 | let lastScrollTopRef = useRef(0); 62 | 63 | useOnScroll(scrollContainerRef, () => { 64 | let el = scrollContainerRef.current; 65 | if (!el) return; 66 | 67 | // If autoscrolling is true and we scrolled up... 68 | if (isAutoscrolling && el.scrollTop < lastScrollTopRef.current) { 69 | setIsAutoscrolling(false); 70 | if (animationControlsRef.current) { 71 | animationControlsRef.current.stop(); 72 | } 73 | 74 | // If autoscrolling is false and we scrolled to the bottom... 75 | } else if ( 76 | !isAutoscrolling && 77 | el.scrollTop === el.scrollHeight - el.clientHeight 78 | ) { 79 | setIsAutoscrolling(true); 80 | } 81 | 82 | lastScrollTopRef.current = el.scrollTop; 83 | }); 84 | 85 | useOnResize(contentRef, ({ currentHeight, previousHeight }) => { 86 | let scrollContainerEl = scrollContainerRef.current; 87 | let whiteSpaceEl = whiteSpaceRef.current; 88 | 89 | if (!scrollContainerEl || !whiteSpaceEl) return; 90 | if (currentHeight < scrollContainerEl.clientHeight) return; 91 | if (currentHeight === previousHeight) return; 92 | 93 | let change = currentHeight - previousHeight; 94 | // Ignore content getting smaller due to resize 95 | if (change < 0) return; 96 | 97 | let newHeight = whiteSpaceEl.clientHeight - change; 98 | 99 | if (newHeight > 0) { 100 | whiteSpaceEl.style.height = `${whiteSpaceEl.clientHeight - change}px`; 101 | } else { 102 | whiteSpaceEl.style.height = `100px`; 103 | let scrollContainerEl = scrollContainerRef.current; 104 | if (scrollContainerEl && isAutoscrolling) { 105 | let end = 106 | scrollContainerEl.scrollHeight - scrollContainerEl.clientHeight; 107 | 108 | if (animationControlsRef.current) { 109 | animationControlsRef.current.stop(); 110 | } 111 | 112 | let controls = animate(scrollContainerEl.scrollTop, end, { 113 | type: "spring", 114 | bounce: 0, 115 | duration: 0.5, 116 | onUpdate: (latest) => scrollContainerEl.scrollTo({ top: latest }), 117 | }); 118 | animationControlsRef.current = controls; 119 | } 120 | } 121 | }); 122 | 123 | return ( 124 |
125 |
{children}
126 |
127 |
128 | ); 129 | } 130 | 131 | export function AutoScrollerButton({ 132 | children, 133 | ...rest 134 | }: Omit, "onClick">) { 135 | let { 136 | isAutoscrolling, 137 | setIsAutoscrolling, 138 | scrollContainerRef, 139 | animationControlsRef, 140 | } = useContext(AutoScrollerContext); 141 | 142 | function handleClick() { 143 | setIsAutoscrolling(true); 144 | let scrollContainerEl = scrollContainerRef.current; 145 | if (!scrollContainerEl) return; 146 | 147 | let end = scrollContainerEl.scrollHeight - scrollContainerEl.clientHeight; 148 | let controls = animate(scrollContainerEl.scrollTop, end, { 149 | type: "spring", 150 | bounce: 0, 151 | duration: 0.5, 152 | onUpdate: (latest) => scrollContainerEl.scrollTo({ top: latest }), 153 | }); 154 | animationControlsRef.current = controls; 155 | } 156 | 157 | if (isAutoscrolling) return; 158 | 159 | return ( 160 | 163 | ); 164 | } 165 | 166 | function useOnResize( 167 | ref: MutableRefObject, 168 | callback: (args: { currentHeight: number; previousHeight: number }) => void, 169 | ) { 170 | // Create a ref to store the observer 171 | const observer = useRef(null); 172 | const previousHeightRef = useRef(null); 173 | 174 | useEffect(() => { 175 | // Ensure the ref.current is not null before creating the observer 176 | if (!ref.current) return; 177 | 178 | // Initialize the ResizeObserver with the callback function 179 | observer.current = new ResizeObserver((entries) => { 180 | for (let entry of entries) { 181 | const currentHeight = entry.contentRect.height; 182 | 183 | // Get the previous height; if it's null, initialize it with the current height 184 | const previousHeight = previousHeightRef.current ?? currentHeight; 185 | 186 | // Update the previous height ref 187 | previousHeightRef.current = currentHeight; 188 | 189 | // Call the callback with the change in height 190 | callback({ currentHeight, previousHeight }); 191 | } 192 | }); 193 | 194 | // Start observing the element referenced by ref 195 | observer.current.observe(ref.current); 196 | 197 | // Cleanup function to disconnect the observer when the component unmounts or ref changes 198 | return () => { 199 | if (observer.current) { 200 | observer.current.disconnect(); 201 | observer.current = null; // Clear the observer ref 202 | } 203 | }; 204 | }, [ref, callback]); 205 | } 206 | 207 | function useOnScroll( 208 | ref: RefObject, 209 | callback: (event: Event) => void, 210 | ): void { 211 | useEffect(() => { 212 | const element = ref.current; 213 | 214 | if (!element) return; 215 | 216 | // Define the scroll event handler 217 | function handleScroll(event: Event) { 218 | callback(event); 219 | } 220 | 221 | // Add the event listener to the element 222 | element.addEventListener("scroll", handleScroll); 223 | 224 | // Cleanup function to remove the event listener 225 | return () => { 226 | element.removeEventListener("scroll", handleScroll); 227 | }; 228 | }, [ref, callback]); 229 | } 230 | -------------------------------------------------------------------------------- /src/app/components/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as Headless from '@headlessui/react' 2 | import clsx from 'clsx' 3 | import type React from 'react' 4 | import { Text } from './text' 5 | 6 | const sizes = { 7 | xs: 'sm:max-w-xs', 8 | sm: 'sm:max-w-sm', 9 | md: 'sm:max-w-md', 10 | lg: 'sm:max-w-lg', 11 | xl: 'sm:max-w-xl', 12 | '2xl': 'sm:max-w-2xl', 13 | '3xl': 'sm:max-w-3xl', 14 | '4xl': 'sm:max-w-4xl', 15 | '5xl': 'sm:max-w-5xl', 16 | } 17 | 18 | export function Alert({ 19 | size = 'md', 20 | className, 21 | children, 22 | ...props 23 | }: { size?: keyof typeof sizes; className?: string; children: React.ReactNode } & Omit< 24 | Headless.DialogProps, 25 | 'as' | 'className' 26 | >) { 27 | return ( 28 | 29 | 33 | 34 |
35 |
36 | 45 | {children} 46 | 47 |
48 |
49 |
50 | ) 51 | } 52 | 53 | export function AlertTitle({ 54 | className, 55 | ...props 56 | }: { className?: string } & Omit) { 57 | return ( 58 | 65 | ) 66 | } 67 | 68 | export function AlertDescription({ 69 | className, 70 | ...props 71 | }: { className?: string } & Omit, 'as' | 'className'>) { 72 | return ( 73 | 78 | ) 79 | } 80 | 81 | export function AlertBody({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) { 82 | return
83 | } 84 | 85 | export function AlertActions({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) { 86 | return ( 87 |
94 | ) 95 | } 96 | -------------------------------------------------------------------------------- /src/app/components/avatar.tsx: -------------------------------------------------------------------------------- 1 | import * as Headless from '@headlessui/react' 2 | import clsx from 'clsx' 3 | import React, { forwardRef } from 'react' 4 | import { TouchTarget } from './button' 5 | import { Link } from './link' 6 | 7 | type AvatarProps = { 8 | src?: string | null 9 | square?: boolean 10 | initials?: string 11 | alt?: string 12 | className?: string 13 | } 14 | 15 | export function Avatar({ 16 | src = null, 17 | square = false, 18 | initials, 19 | alt = '', 20 | className, 21 | ...props 22 | }: AvatarProps & React.ComponentPropsWithoutRef<'span'>) { 23 | return ( 24 | 36 | {initials && ( 37 | 42 | {alt && {alt}} 43 | 44 | {initials} 45 | 46 | 47 | )} 48 | {src && {alt}} 49 | 50 | ) 51 | } 52 | 53 | export const AvatarButton = forwardRef(function AvatarButton( 54 | { 55 | src, 56 | square = false, 57 | initials, 58 | alt, 59 | className, 60 | ...props 61 | }: AvatarProps & 62 | (Omit | Omit, 'className'>), 63 | ref: React.ForwardedRef 64 | ) { 65 | let classes = clsx( 66 | className, 67 | square ? 'rounded-[20%]' : 'rounded-full', 68 | 'relative inline-grid focus:outline-none data-[focus]:outline data-[focus]:outline-2 data-[focus]:outline-offset-2 data-[focus]:outline-blue-500' 69 | ) 70 | 71 | return 'href' in props ? ( 72 | }> 73 | 74 | 75 | 76 | 77 | ) : ( 78 | 79 | 80 | 81 | 82 | 83 | ) 84 | }) 85 | -------------------------------------------------------------------------------- /src/app/components/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as Headless from '@headlessui/react' 2 | import clsx from 'clsx' 3 | import React, { forwardRef } from 'react' 4 | import { TouchTarget } from './button' 5 | import { Link } from './link' 6 | 7 | const colors = { 8 | red: 'bg-red-500/15 text-red-700 group-data-[hover]:bg-red-500/25 dark:bg-red-500/10 dark:text-red-400 dark:group-data-[hover]:bg-red-500/20', 9 | orange: 10 | 'bg-orange-500/15 text-orange-700 group-data-[hover]:bg-orange-500/25 dark:bg-orange-500/10 dark:text-orange-400 dark:group-data-[hover]:bg-orange-500/20', 11 | amber: 12 | 'bg-amber-400/20 text-amber-700 group-data-[hover]:bg-amber-400/30 dark:bg-amber-400/10 dark:text-amber-400 dark:group-data-[hover]:bg-amber-400/15', 13 | yellow: 14 | 'bg-yellow-400/20 text-yellow-700 group-data-[hover]:bg-yellow-400/30 dark:bg-yellow-400/10 dark:text-yellow-300 dark:group-data-[hover]:bg-yellow-400/15', 15 | lime: 'bg-lime-400/20 text-lime-700 group-data-[hover]:bg-lime-400/30 dark:bg-lime-400/10 dark:text-lime-300 dark:group-data-[hover]:bg-lime-400/15', 16 | green: 17 | 'bg-green-500/15 text-green-700 group-data-[hover]:bg-green-500/25 dark:bg-green-500/10 dark:text-green-400 dark:group-data-[hover]:bg-green-500/20', 18 | emerald: 19 | 'bg-emerald-500/15 text-emerald-700 group-data-[hover]:bg-emerald-500/25 dark:bg-emerald-500/10 dark:text-emerald-400 dark:group-data-[hover]:bg-emerald-500/20', 20 | teal: 'bg-teal-500/15 text-teal-700 group-data-[hover]:bg-teal-500/25 dark:bg-teal-500/10 dark:text-teal-300 dark:group-data-[hover]:bg-teal-500/20', 21 | cyan: 'bg-cyan-400/20 text-cyan-700 group-data-[hover]:bg-cyan-400/30 dark:bg-cyan-400/10 dark:text-cyan-300 dark:group-data-[hover]:bg-cyan-400/15', 22 | sky: 'bg-sky-500/15 text-sky-700 group-data-[hover]:bg-sky-500/25 dark:bg-sky-500/10 dark:text-sky-300 dark:group-data-[hover]:bg-sky-500/20', 23 | blue: 'bg-blue-500/15 text-blue-700 group-data-[hover]:bg-blue-500/25 dark:text-blue-400 dark:group-data-[hover]:bg-blue-500/25', 24 | indigo: 25 | 'bg-indigo-500/15 text-indigo-700 group-data-[hover]:bg-indigo-500/25 dark:text-indigo-400 dark:group-data-[hover]:bg-indigo-500/20', 26 | violet: 27 | 'bg-violet-500/15 text-violet-700 group-data-[hover]:bg-violet-500/25 dark:text-violet-400 dark:group-data-[hover]:bg-violet-500/20', 28 | purple: 29 | 'bg-purple-500/15 text-purple-700 group-data-[hover]:bg-purple-500/25 dark:text-purple-400 dark:group-data-[hover]:bg-purple-500/20', 30 | fuchsia: 31 | 'bg-fuchsia-400/15 text-fuchsia-700 group-data-[hover]:bg-fuchsia-400/25 dark:bg-fuchsia-400/10 dark:text-fuchsia-400 dark:group-data-[hover]:bg-fuchsia-400/20', 32 | pink: 'bg-pink-400/15 text-pink-700 group-data-[hover]:bg-pink-400/25 dark:bg-pink-400/10 dark:text-pink-400 dark:group-data-[hover]:bg-pink-400/20', 33 | rose: 'bg-rose-400/15 text-rose-700 group-data-[hover]:bg-rose-400/25 dark:bg-rose-400/10 dark:text-rose-400 dark:group-data-[hover]:bg-rose-400/20', 34 | zinc: 'bg-zinc-600/10 text-zinc-700 group-data-[hover]:bg-zinc-600/20 dark:bg-white/5 dark:text-zinc-400 dark:group-data-[hover]:bg-white/10', 35 | } 36 | 37 | type BadgeProps = { color?: keyof typeof colors } 38 | 39 | export function Badge({ color = 'zinc', className, ...props }: BadgeProps & React.ComponentPropsWithoutRef<'span'>) { 40 | return ( 41 | 49 | ) 50 | } 51 | 52 | export const BadgeButton = forwardRef(function BadgeButton( 53 | { 54 | color = 'zinc', 55 | className, 56 | children, 57 | ...props 58 | }: BadgeProps & { className?: string; children: React.ReactNode } & ( 59 | | Omit 60 | | Omit, 'className'> 61 | ), 62 | ref: React.ForwardedRef 63 | ) { 64 | let classes = clsx( 65 | className, 66 | 'group relative inline-flex rounded-md focus:outline-none data-[focus]:outline data-[focus]:outline-2 data-[focus]:outline-offset-2 data-[focus]:outline-blue-500' 67 | ) 68 | 69 | return 'href' in props ? ( 70 | }> 71 | 72 | {children} 73 | 74 | 75 | ) : ( 76 | 77 | 78 | {children} 79 | 80 | 81 | ) 82 | }) 83 | -------------------------------------------------------------------------------- /src/app/components/button.tsx: -------------------------------------------------------------------------------- 1 | import * as Headless from '@headlessui/react' 2 | import clsx from 'clsx' 3 | import React, { forwardRef } from 'react' 4 | import { Link } from './link' 5 | 6 | const styles = { 7 | base: [ 8 | // Base 9 | 'relative isolate inline-flex items-center justify-center gap-x-2 rounded-lg border text-base/6 font-semibold', 10 | // Sizing 11 | 'px-[calc(theme(spacing[3.5])-1px)] py-[calc(theme(spacing[2.5])-1px)] sm:px-[calc(theme(spacing.3)-1px)] sm:py-[calc(theme(spacing[1.5])-1px)] sm:text-sm/6', 12 | // Focus 13 | 'focus:outline-none data-[focus]:outline data-[focus]:outline-2 data-[focus]:outline-offset-2 data-[focus]:outline-blue-500', 14 | // Disabled 15 | 'data-[disabled]:opacity-50', 16 | // Icon 17 | '[&>[data-slot=icon]]:-mx-0.5 [&>[data-slot=icon]]:my-0.5 [&>[data-slot=icon]]:size-5 [&>[data-slot=icon]]:shrink-0 [&>[data-slot=icon]]:text-[--btn-icon] [&>[data-slot=icon]]:sm:my-1 [&>[data-slot=icon]]:sm:size-4 forced-colors:[--btn-icon:ButtonText] forced-colors:data-[hover]:[--btn-icon:ButtonText]', 18 | ], 19 | solid: [ 20 | // Optical border, implemented as the button background to avoid corner artifacts 21 | 'border-transparent bg-[--btn-border]', 22 | // Dark mode: border is rendered on `after` so background is set to button background 23 | 'dark:bg-[--btn-bg]', 24 | // Button background, implemented as foreground layer to stack on top of pseudo-border layer 25 | 'before:absolute before:inset-0 before:-z-10 before:rounded-[calc(theme(borderRadius.lg)-1px)] before:bg-[--btn-bg]', 26 | // Drop shadow, applied to the inset `before` layer so it blends with the border 27 | 'before:shadow', 28 | // Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo 29 | 'dark:before:hidden', 30 | // Dark mode: Subtle white outline is applied using a border 31 | 'dark:border-white/5', 32 | // Shim/overlay, inset to match button foreground and used for hover state + highlight shadow 33 | 'after:absolute after:inset-0 after:-z-10 after:rounded-[calc(theme(borderRadius.lg)-1px)]', 34 | // Inner highlight shadow 35 | 'after:shadow-[shadow:inset_0_1px_theme(colors.white/15%)]', 36 | // White overlay on hover 37 | 'after:data-[active]:bg-[--btn-hover-overlay] after:data-[hover]:bg-[--btn-hover-overlay]', 38 | // Dark mode: `after` layer expands to cover entire button 39 | 'dark:after:-inset-px dark:after:rounded-lg', 40 | // Disabled 41 | 'before:data-[disabled]:shadow-none after:data-[disabled]:shadow-none', 42 | ], 43 | outline: [ 44 | // Base 45 | 'border-zinc-950/10 text-zinc-950 data-[active]:bg-zinc-950/[2.5%] data-[hover]:bg-zinc-950/[2.5%]', 46 | // Dark mode 47 | 'dark:border-white/15 dark:text-white dark:[--btn-bg:transparent] dark:data-[active]:bg-white/5 dark:data-[hover]:bg-white/5', 48 | // Icon 49 | '[--btn-icon:theme(colors.zinc.500)] data-[active]:[--btn-icon:theme(colors.zinc.700)] data-[hover]:[--btn-icon:theme(colors.zinc.700)] dark:data-[active]:[--btn-icon:theme(colors.zinc.400)] dark:data-[hover]:[--btn-icon:theme(colors.zinc.400)]', 50 | ], 51 | plain: [ 52 | // Base 53 | 'border-transparent text-zinc-950 data-[active]:bg-zinc-950/5 data-[hover]:bg-zinc-950/5', 54 | // Dark mode 55 | 'dark:text-white dark:data-[active]:bg-white/10 dark:data-[hover]:bg-white/10', 56 | // Icon 57 | '[--btn-icon:theme(colors.zinc.500)] data-[active]:[--btn-icon:theme(colors.zinc.700)] data-[hover]:[--btn-icon:theme(colors.zinc.700)] dark:[--btn-icon:theme(colors.zinc.500)] dark:data-[active]:[--btn-icon:theme(colors.zinc.400)] dark:data-[hover]:[--btn-icon:theme(colors.zinc.400)]', 58 | ], 59 | colors: { 60 | 'dark/zinc': [ 61 | 'text-white [--btn-bg:theme(colors.zinc.900)] [--btn-border:theme(colors.zinc.950/90%)] [--btn-hover-overlay:theme(colors.white/10%)]', 62 | 'dark:text-white dark:[--btn-bg:theme(colors.zinc.600)] dark:[--btn-hover-overlay:theme(colors.white/5%)]', 63 | '[--btn-icon:theme(colors.zinc.400)] data-[active]:[--btn-icon:theme(colors.zinc.300)] data-[hover]:[--btn-icon:theme(colors.zinc.300)]', 64 | ], 65 | light: [ 66 | 'text-zinc-950 [--btn-bg:white] [--btn-border:theme(colors.zinc.950/10%)] [--btn-hover-overlay:theme(colors.zinc.950/2.5%)] data-[active]:[--btn-border:theme(colors.zinc.950/15%)] data-[hover]:[--btn-border:theme(colors.zinc.950/15%)]', 67 | 'dark:text-white dark:[--btn-hover-overlay:theme(colors.white/5%)] dark:[--btn-bg:theme(colors.zinc.800)]', 68 | '[--btn-icon:theme(colors.zinc.500)] data-[active]:[--btn-icon:theme(colors.zinc.700)] data-[hover]:[--btn-icon:theme(colors.zinc.700)] dark:[--btn-icon:theme(colors.zinc.500)] dark:data-[active]:[--btn-icon:theme(colors.zinc.400)] dark:data-[hover]:[--btn-icon:theme(colors.zinc.400)]', 69 | ], 70 | 'dark/white': [ 71 | 'text-white [--btn-bg:theme(colors.zinc.900)] [--btn-border:theme(colors.zinc.950/90%)] [--btn-hover-overlay:theme(colors.white/10%)]', 72 | 'dark:text-zinc-950 dark:[--btn-bg:white] dark:[--btn-hover-overlay:theme(colors.zinc.950/5%)]', 73 | '[--btn-icon:theme(colors.zinc.400)] data-[active]:[--btn-icon:theme(colors.zinc.300)] data-[hover]:[--btn-icon:theme(colors.zinc.300)] dark:[--btn-icon:theme(colors.zinc.500)] dark:data-[active]:[--btn-icon:theme(colors.zinc.400)] dark:data-[hover]:[--btn-icon:theme(colors.zinc.400)]', 74 | ], 75 | dark: [ 76 | 'text-white [--btn-bg:theme(colors.zinc.900)] [--btn-border:theme(colors.zinc.950/90%)] [--btn-hover-overlay:theme(colors.white/10%)]', 77 | 'dark:[--btn-hover-overlay:theme(colors.white/5%)] dark:[--btn-bg:theme(colors.zinc.800)]', 78 | '[--btn-icon:theme(colors.zinc.400)] data-[active]:[--btn-icon:theme(colors.zinc.300)] data-[hover]:[--btn-icon:theme(colors.zinc.300)]', 79 | ], 80 | white: [ 81 | 'text-zinc-950 [--btn-bg:white] [--btn-border:theme(colors.zinc.950/10%)] [--btn-hover-overlay:theme(colors.zinc.950/2.5%)] data-[active]:[--btn-border:theme(colors.zinc.950/15%)] data-[hover]:[--btn-border:theme(colors.zinc.950/15%)]', 82 | 'dark:[--btn-hover-overlay:theme(colors.zinc.950/5%)]', 83 | '[--btn-icon:theme(colors.zinc.400)] data-[active]:[--btn-icon:theme(colors.zinc.500)] data-[hover]:[--btn-icon:theme(colors.zinc.500)]', 84 | ], 85 | zinc: [ 86 | 'text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.zinc.600)] [--btn-border:theme(colors.zinc.700/90%)]', 87 | 'dark:[--btn-hover-overlay:theme(colors.white/5%)]', 88 | '[--btn-icon:theme(colors.zinc.400)] data-[active]:[--btn-icon:theme(colors.zinc.300)] data-[hover]:[--btn-icon:theme(colors.zinc.300)]', 89 | ], 90 | indigo: [ 91 | 'text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.indigo.500)] [--btn-border:theme(colors.indigo.600/90%)]', 92 | '[--btn-icon:theme(colors.indigo.300)] data-[active]:[--btn-icon:theme(colors.indigo.200)] data-[hover]:[--btn-icon:theme(colors.indigo.200)]', 93 | ], 94 | cyan: [ 95 | 'text-cyan-950 [--btn-bg:theme(colors.cyan.300)] [--btn-border:theme(colors.cyan.400/80%)] [--btn-hover-overlay:theme(colors.white/25%)]', 96 | '[--btn-icon:theme(colors.cyan.500)]', 97 | ], 98 | red: [ 99 | 'text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.red.600)] [--btn-border:theme(colors.red.700/90%)]', 100 | '[--btn-icon:theme(colors.red.300)] data-[active]:[--btn-icon:theme(colors.red.200)] data-[hover]:[--btn-icon:theme(colors.red.200)]', 101 | ], 102 | orange: [ 103 | 'text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.orange.500)] [--btn-border:theme(colors.orange.600/90%)]', 104 | '[--btn-icon:theme(colors.orange.300)] data-[active]:[--btn-icon:theme(colors.orange.200)] data-[hover]:[--btn-icon:theme(colors.orange.200)]', 105 | ], 106 | amber: [ 107 | 'text-amber-950 [--btn-hover-overlay:theme(colors.white/25%)] [--btn-bg:theme(colors.amber.400)] [--btn-border:theme(colors.amber.500/80%)]', 108 | '[--btn-icon:theme(colors.amber.600)]', 109 | ], 110 | yellow: [ 111 | 'text-yellow-950 [--btn-hover-overlay:theme(colors.white/25%)] [--btn-bg:theme(colors.yellow.300)] [--btn-border:theme(colors.yellow.400/80%)]', 112 | '[--btn-icon:theme(colors.yellow.600)] data-[active]:[--btn-icon:theme(colors.yellow.700)] data-[hover]:[--btn-icon:theme(colors.yellow.700)]', 113 | ], 114 | lime: [ 115 | 'text-lime-950 [--btn-hover-overlay:theme(colors.white/25%)] [--btn-bg:theme(colors.lime.300)] [--btn-border:theme(colors.lime.400/80%)]', 116 | '[--btn-icon:theme(colors.lime.600)] data-[active]:[--btn-icon:theme(colors.lime.700)] data-[hover]:[--btn-icon:theme(colors.lime.700)]', 117 | ], 118 | green: [ 119 | 'text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.green.600)] [--btn-border:theme(colors.green.700/90%)]', 120 | '[--btn-icon:theme(colors.white/60%)] data-[active]:[--btn-icon:theme(colors.white/80%)] data-[hover]:[--btn-icon:theme(colors.white/80%)]', 121 | ], 122 | emerald: [ 123 | 'text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.emerald.600)] [--btn-border:theme(colors.emerald.700/90%)]', 124 | '[--btn-icon:theme(colors.white/60%)] data-[active]:[--btn-icon:theme(colors.white/80%)] data-[hover]:[--btn-icon:theme(colors.white/80%)]', 125 | ], 126 | teal: [ 127 | 'text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.teal.600)] [--btn-border:theme(colors.teal.700/90%)]', 128 | '[--btn-icon:theme(colors.white/60%)] data-[active]:[--btn-icon:theme(colors.white/80%)] data-[hover]:[--btn-icon:theme(colors.white/80%)]', 129 | ], 130 | sky: [ 131 | 'text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.sky.500)] [--btn-border:theme(colors.sky.600/80%)]', 132 | '[--btn-icon:theme(colors.white/60%)] data-[active]:[--btn-icon:theme(colors.white/80%)] data-[hover]:[--btn-icon:theme(colors.white/80%)]', 133 | ], 134 | blue: [ 135 | 'text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.blue.600)] [--btn-border:theme(colors.blue.700/90%)]', 136 | '[--btn-icon:theme(colors.blue.400)] data-[active]:[--btn-icon:theme(colors.blue.300)] data-[hover]:[--btn-icon:theme(colors.blue.300)]', 137 | ], 138 | violet: [ 139 | 'text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.violet.500)] [--btn-border:theme(colors.violet.600/90%)]', 140 | '[--btn-icon:theme(colors.violet.300)] data-[active]:[--btn-icon:theme(colors.violet.200)] data-[hover]:[--btn-icon:theme(colors.violet.200)]', 141 | ], 142 | purple: [ 143 | 'text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.purple.500)] [--btn-border:theme(colors.purple.600/90%)]', 144 | '[--btn-icon:theme(colors.purple.300)] data-[active]:[--btn-icon:theme(colors.purple.200)] data-[hover]:[--btn-icon:theme(colors.purple.200)]', 145 | ], 146 | fuchsia: [ 147 | 'text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.fuchsia.500)] [--btn-border:theme(colors.fuchsia.600/90%)]', 148 | '[--btn-icon:theme(colors.fuchsia.300)] data-[active]:[--btn-icon:theme(colors.fuchsia.200)] data-[hover]:[--btn-icon:theme(colors.fuchsia.200)]', 149 | ], 150 | pink: [ 151 | 'text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.pink.500)] [--btn-border:theme(colors.pink.600/90%)]', 152 | '[--btn-icon:theme(colors.pink.300)] data-[active]:[--btn-icon:theme(colors.pink.200)] data-[hover]:[--btn-icon:theme(colors.pink.200)]', 153 | ], 154 | rose: [ 155 | 'text-white [--btn-hover-overlay:theme(colors.white/10%)] [--btn-bg:theme(colors.rose.500)] [--btn-border:theme(colors.rose.600/90%)]', 156 | '[--btn-icon:theme(colors.rose.300)] data-[active]:[--btn-icon:theme(colors.rose.200)] data-[hover]:[--btn-icon:theme(colors.rose.200)]', 157 | ], 158 | }, 159 | } 160 | 161 | type ButtonProps = ( 162 | | { color?: keyof typeof styles.colors; outline?: never; plain?: never } 163 | | { color?: never; outline: true; plain?: never } 164 | | { color?: never; outline?: never; plain: true } 165 | ) & { className?: string; children: React.ReactNode } & ( 166 | | Omit 167 | | Omit, 'className'> 168 | ) 169 | 170 | export const Button = forwardRef(function Button( 171 | { color, outline, plain, className, children, ...props }: ButtonProps, 172 | ref: React.ForwardedRef 173 | ) { 174 | let classes = clsx( 175 | className, 176 | styles.base, 177 | outline ? styles.outline : plain ? styles.plain : clsx(styles.solid, styles.colors[color ?? 'dark/zinc']) 178 | ) 179 | 180 | return 'href' in props ? ( 181 | }> 182 | {children} 183 | 184 | ) : ( 185 | 186 | {children} 187 | 188 | ) 189 | }) 190 | 191 | /** 192 | * Expand the hit area to at least 44×44px on touch devices 193 | */ 194 | export function TouchTarget({ children }: { children: React.ReactNode }) { 195 | return ( 196 | <> 197 |