├── .github └── workflows │ └── test.js.yml ├── .gitignore ├── .swcrc ├── LICENSE ├── README.md ├── docs ├── .eslintrc.cjs ├── .gitignore ├── index.html ├── package.json ├── postcss.config.js ├── public │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ └── site.webmanifest ├── src │ ├── components │ │ ├── ActionButton.tsx │ │ ├── Code.tsx │ │ ├── CodeBlock.tsx │ │ ├── DarkModeSwitch.tsx │ │ ├── DemoAnimationExampleRow.tsx │ │ ├── HeaderNavAnchor.tsx │ │ ├── Heading.tsx │ │ ├── Link.tsx │ │ ├── Logo.tsx │ │ ├── PageBackground.tsx │ │ ├── Paragraph.tsx │ │ ├── Separator.tsx │ │ └── Skeleton.tsx │ ├── css │ │ ├── keyframes.css │ │ └── prism.css │ ├── demos │ │ ├── AppearDemo.tsx │ │ ├── DemoPlaceholderContent.tsx │ │ ├── DemoTriggerLine.tsx │ │ ├── DemoWrapper.tsx │ │ ├── MultiRangeDemo.tsx │ │ ├── ProgressBarDemo.tsx │ │ ├── RangeDemo.tsx │ │ ├── SupportsDemo.tsx │ │ └── TimelineOverrideDemo.tsx │ ├── docs │ │ ├── Docs.tsx │ │ ├── DocsTable.tsx │ │ └── DocsTableRow.tsx │ ├── fonts │ │ ├── inter-variable.woff2 │ │ └── inter.css │ ├── index.css │ ├── layouts │ │ └── Page.tsx │ ├── main.tsx │ ├── partials │ │ ├── Animations.tsx │ │ ├── Footer.tsx │ │ ├── Installation.tsx │ │ ├── MainTitle.tsx │ │ ├── Me.tsx │ │ └── Nav.tsx │ ├── utils │ │ ├── addWithSpace.ts │ │ ├── codeExamples.ts │ │ └── demoExamples.ts │ ├── views │ │ ├── DocsView.tsx │ │ ├── HowToView.tsx │ │ └── TechView.tsx │ └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json ├── vercel.json └── vite.config.ts ├── package.json ├── src └── index.ts ├── tests ├── content.ts ├── expect.ts ├── index.test.ts └── utils.ts └── tsconfig.json /.github/workflows/test.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Build and test 5 | 6 | on: 7 | push: 8 | pull_request: 9 | workflow_dispatch: 10 | 11 | jobs: 12 | build: 13 | 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [18.x] 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | cache: 'npm' 27 | - run: npm ci 28 | - run: npm run build --if-present 29 | - run: npm test 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | dist 4 | -------------------------------------------------------------------------------- /.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/swcrc", 3 | "module": { 4 | "type": "commonjs", 5 | "strict": false, 6 | "strictMode": true, 7 | "lazy": false, 8 | "noInterop": false 9 | }, 10 | "jsc": { 11 | "parser": { 12 | "syntax": "typescript", 13 | "tsx": false 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Adam Plesnik adamplesnik.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Scroll-driven Animations for Tailwind CSS 2 | 3 | !['License'](https://flat.badgen.net/github/license/adamplesnik/tailwindcss-scroll-driven-animations) 4 | !['Checks'](https://flat.badgen.net/github/checks/adamplesnik/tailwindcss-scroll-driven-animations) 5 | !['Stars'](https://flat.badgen.net/github/stars/adamplesnik/tailwindcss-scroll-driven-animations) 6 | 7 | **Unofficial** and experimental plugin for Tailwind CSS v3.4+ that provides utilities for scroll-driven animations. 8 | 9 | `unofficial` `experimental` `chrome-115+` 10 | 11 | ## Installation 12 | 13 | Install the plugin from npm: 14 | 15 | ```sh 16 | npm install @adam.plesnik/tailwindcss-scroll-driven-animations 17 | ``` 18 | 19 | Then add the plugin to your `tailwind.config.js`: 20 | 21 | ```js 22 | module.exports = { 23 | plugins: [ 24 | require('@adam.plesnik/tailwindcss-scroll-driven-animations'), 25 | // ... 26 | ], 27 | } 28 | ``` 29 | 30 | ## Usage 31 | 32 | The plugin provides utilities for a subset of CSS scroll-driven animation properties. 33 | 34 | - `animation-timeline` 35 | - `animation-range` 36 | - `scroll-timeline`, `view-timeline` 37 | - `timeline-scope` 38 | 39 | ### Animation Timeline 40 | 41 | The single most impressive feature of scroll-driven animations is an anonymous animation timeline. It allows user to easily trigger anything just by scrolling the page. The plugin allows user to use the `.timeline` CSS class which defaults to `animation-timeline: scroll(y)` and also provides an option to set custom timeline name with a modifier. 42 | 43 | ### Scroll and View Timeline 44 | 45 | Scroll and View timelines provide user with better control over the animations. Both `.scroll-timeline` and `.view-timeline` are meant to be used with modifiers to set the timeline name. 46 | 47 | ### Range 48 | 49 | Animation range controls start and end of an animation. Utility class `.range` offers various options along with a possibility to use length modifiers. 50 | 51 | ### Scope 52 | 53 | Timeline scope allows to control animated elements outside the parent which defines the timeline. Utility `.scope` should be used with a modifier to define the timeline name set by `.scroll-timeline` or `.view-timeline`. 54 | 55 | ### Browser Support 56 | 57 | Scroll-driven animations are not broadly supported yet. Use the `no-animations:...` variant for fallback styling. 58 | -------------------------------------------------------------------------------- /docs/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /docs/.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 | 26 | package-lock.json 27 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Scroll-driven Animations Plugin for Tailwind CSS 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tailwindcss-scroll-driven-animations-docu", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "prettier": { 13 | "printWidth": 100, 14 | "semi": false, 15 | "singleQuote": true, 16 | "trailingComma": "es5", 17 | "plugins": [ 18 | "prettier-plugin-tailwindcss" 19 | ] 20 | }, 21 | "dependencies": { 22 | "@vercel/analytics": "^1.2.2", 23 | "framer-motion": "^11.0.24", 24 | "lucide-react": "^0.363.0", 25 | "prismjs": "^1.29.0", 26 | "react": "^18.2.0", 27 | "react-dom": "^18.2.0", 28 | "react-router-dom": "^6.22.3" 29 | }, 30 | "devDependencies": { 31 | "@adam.plesnik/tailwindcss-scroll-driven-animations": "^0.2.7", 32 | "@types/node": "^20.11.30", 33 | "@types/prismjs": "^1.26.3", 34 | "@types/react": "^18.2.66", 35 | "@types/react-dom": "^18.2.22", 36 | "@typescript-eslint/eslint-plugin": "^7.2.0", 37 | "@typescript-eslint/parser": "^7.2.0", 38 | "@vitejs/plugin-react": "^4.2.1", 39 | "autoprefixer": "^10.4.19", 40 | "babel-plugin-prismjs": "^2.1.0", 41 | "eslint": "^8.57.0", 42 | "eslint-plugin-react-hooks": "^4.6.0", 43 | "eslint-plugin-react-refresh": "^0.4.6", 44 | "postcss": "^8.4.38", 45 | "postcss-import": "^16.1.0", 46 | "prettier": "^3.2.5", 47 | "prettier-plugin-tailwindcss": "^0.5.12", 48 | "tailwindcss": "^3.4.1", 49 | "typescript": "^5.2.2", 50 | "vite": "^5.2.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /docs/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | 'postcss-import': {}, 4 | tailwindcss: {}, 5 | autoprefixer: {}, 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /docs/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamplesnik/tailwindcss-scroll-driven-animations/HEAD/docs/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /docs/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamplesnik/tailwindcss-scroll-driven-animations/HEAD/docs/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /docs/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamplesnik/tailwindcss-scroll-driven-animations/HEAD/docs/public/apple-touch-icon.png -------------------------------------------------------------------------------- /docs/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamplesnik/tailwindcss-scroll-driven-animations/HEAD/docs/public/favicon-16x16.png -------------------------------------------------------------------------------- /docs/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamplesnik/tailwindcss-scroll-driven-animations/HEAD/docs/public/favicon-32x32.png -------------------------------------------------------------------------------- /docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamplesnik/tailwindcss-scroll-driven-animations/HEAD/docs/public/favicon.ico -------------------------------------------------------------------------------- /docs/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /docs/src/components/ActionButton.tsx: -------------------------------------------------------------------------------- 1 | import { LucideIcon } from 'lucide-react' 2 | import { MouseEvent, MouseEventHandler, useState } from 'react' 3 | 4 | const ActionButton = ({ 5 | clickAction, 6 | Icon, 7 | IconOnClick = undefined, 8 | tooltip = '', 9 | }: ReplayButtonProps) => { 10 | const [clicked, setClicked] = useState(false) 11 | function handleClick(e: MouseEvent) { 12 | clickAction(e) 13 | setClicked(true) 14 | setTimeout(() => setClicked(false), 2000) 15 | } 16 | return ( 17 |
{ 19 | handleClick(e) 20 | }} 21 | title={tooltip} 22 | className="group cursor-pointer p-1" 23 | > 24 |
31 | 37 | {IconOnClick && ( 38 | 44 | )} 45 |
46 |
47 | ) 48 | } 49 | 50 | export interface ReplayButtonProps { 51 | clickAction: MouseEventHandler 52 | Icon: LucideIcon 53 | IconOnClick?: LucideIcon | undefined 54 | tooltip: string 55 | } 56 | 57 | export default ActionButton 58 | -------------------------------------------------------------------------------- /docs/src/components/Code.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react' 2 | 3 | const Code = ({ children }: PropsWithChildren) => ( 4 | 9 | {children} 10 | 11 | ) 12 | 13 | export interface InlineCodeProps { 14 | children: PropsWithChildren 15 | } 16 | 17 | export default Code 18 | -------------------------------------------------------------------------------- /docs/src/components/CodeBlock.tsx: -------------------------------------------------------------------------------- 1 | import { LucideIcon } from 'lucide-react' 2 | import Prism from 'prismjs' 3 | import { PropsWithChildren, useEffect } from 'react' 4 | import Link from './Link.tsx' 5 | 6 | const CodeBlock = ({ 7 | children, 8 | Icon = undefined, 9 | language = 'javascript', 10 | linkHref = '', 11 | linkText = '', 12 | }: PropsWithChildren) => { 13 | useEffect(() => { 14 | Prism.highlightAll() 15 | }, []) 16 | 17 | return ( 18 |
23 | 26 | {children} 27 | 28 | {linkHref && ( 29 |
34 | {Icon && } 35 | 36 | {linkText ? linkText : linkHref} 37 | 38 |
39 | )} 40 |
41 | ) 42 | } 43 | 44 | export interface CodeProps { 45 | children: PropsWithChildren 46 | Icon?: LucideIcon | undefined 47 | language?: 'javascript' | 'css' | 'html' | 'bash' 48 | linkHref?: string | undefined 49 | linkText?: string | undefined 50 | } 51 | 52 | export default CodeBlock 53 | -------------------------------------------------------------------------------- /docs/src/components/DarkModeSwitch.tsx: -------------------------------------------------------------------------------- 1 | import { motion } from 'framer-motion' 2 | import { Moon, Sun } from 'lucide-react' 3 | import { MouseEventHandler, useState } from 'react' 4 | 5 | const DarkModeSwitch = () => { 6 | const systemDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches 7 | const storageModeDark = localStorage.getItem('mode') === 'dark' 8 | const [darkMode, setDarkMode] = useState(storageModeDark || systemDarkMode ? true : false) 9 | const classList = document.documentElement.classList 10 | darkMode ? classList.add('dark') : classList.remove('dark') 11 | 12 | const switchMode = () => { 13 | localStorage.setItem('mode', darkMode ? 'light' : 'dark') 14 | setDarkMode(!darkMode) 15 | } 16 | 17 | return ( 18 |
22 | 28 | 29 | 30 | 31 |
32 | ) 33 | } 34 | 35 | export interface ModeSwitchProps { 36 | mode: string 37 | onClick: MouseEventHandler 38 | } 39 | 40 | export default DarkModeSwitch 41 | -------------------------------------------------------------------------------- /docs/src/components/DemoAnimationExampleRow.tsx: -------------------------------------------------------------------------------- 1 | import { addWithSpace } from '../utils/addWithSpace' 2 | import Code from './Code' 3 | 4 | const DemoAnimationExampleRow = ({ animations, timeline }: DemoAnimationExampleRowProps) => { 5 | const animationClasses = animations.split(' ') 6 | return ( 7 |
8 |
9 | {animationClasses.map((animation) => ( 10 | {animation} 11 | ))} 12 |
13 |
14 | 0% 15 | 16 | 100% 17 |
24 |
25 |
26 | ) 27 | } 28 | 29 | export interface DemoAnimationExampleRowProps { 30 | animations: string 31 | timeline: string 32 | } 33 | 34 | export default DemoAnimationExampleRow 35 | -------------------------------------------------------------------------------- /docs/src/components/HeaderNavAnchor.tsx: -------------------------------------------------------------------------------- 1 | import { HTMLAttributes, PropsWithChildren } from 'react' 2 | import { NavLink, NavLinkProps } from 'react-router-dom' 3 | import { addWithSpace } from '../utils/addWithSpace' 4 | 5 | const HeaderNavAnchor = ({ children, to, className, external = false }: HeaderNavAnchorProps) => { 6 | return ( 7 | 10 | 'flex items-center justify-center px-2 text-sm font-medium text-zinc-900 transition-opacity duration-200 hover:opacity-100 dark:text-zinc-100 ' + 11 | (isActive ? 'opacity-100 ' : 'opacity-70 ') + 12 | addWithSpace(className) 13 | } 14 | target={external ? '_blank' : ''} 15 | > 16 | {children} 17 | 18 | ) 19 | } 20 | 21 | export type HeaderNavAnchorProps = { 22 | children: PropsWithChildren 23 | to: string 24 | external?: boolean 25 | } & HTMLAttributes 26 | 27 | export default HeaderNavAnchor 28 | -------------------------------------------------------------------------------- /docs/src/components/Heading.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowRight } from 'lucide-react' 2 | import { PropsWithChildren } from 'react' 3 | import { NavLink } from 'react-router-dom' 4 | import { addWithSpace } from '../utils/addWithSpace' 5 | 6 | const Heading = ({ 7 | size = 1, 8 | className = '', 9 | children, 10 | id = '', 11 | href = '', 12 | hrefType = 'documentation', 13 | }: PropsWithChildren) => { 14 | const defaultClasses = 15 | 'relative w-full text-zinc-900 dark:text-zinc-300' + addWithSpace(className) 16 | const anchor = id ? : '' 17 | const link = href ? ( 18 | 19 | 20 | {hrefType === 'documentation' ? 'Documentation' : 'Demo'} 21 | 22 | 23 | 24 | ) : ( 25 | '' 26 | ) 27 | if (size === 1) { 28 | return ( 29 |

30 | {children} 31 | {anchor} 32 |

33 | ) 34 | } else if (size === 2) { 35 | return ( 36 |

37 | {children} 38 | {anchor} 39 |

40 | ) 41 | } else { 42 | return ( 43 |

44 | {children} 45 | {anchor} 46 | {link} 47 |

48 | ) 49 | } 50 | } 51 | 52 | export interface TitleProps { 53 | children: PropsWithChildren 54 | size: 1 | 2 | 3 55 | className?: string 56 | href?: string 57 | hrefType?: 'documentation' | 'demo' 58 | id?: string 59 | } 60 | 61 | export default Heading 62 | -------------------------------------------------------------------------------- /docs/src/components/Link.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react' 2 | import { LucideIcon } from 'lucide-react' 3 | 4 | const Link = ({ 5 | borderWidth = undefined, 6 | children, 7 | className, 8 | href, 9 | Icon = undefined, 10 | iconSize = 16, 11 | iconStrokeWidth = 1.65, 12 | inline = false, 13 | target, 14 | }: PropsWithChildren) => { 15 | return ( 16 |
26 | {children} 27 | {Icon ? ( 28 | 36 | ) : ( 37 | '' 38 | )} 39 | 51 | 52 | ) 53 | } 54 | 55 | export interface LinkProps { 56 | children: PropsWithChildren 57 | href: string 58 | target: string 59 | borderWidth?: undefined | 'narrow' | 'none' | 'huge' 60 | className?: string 61 | Icon?: LucideIcon | undefined 62 | iconSize?: number | 16 63 | iconStrokeWidth?: number | 2 64 | inline?: boolean 65 | } 66 | 67 | export default Link 68 | -------------------------------------------------------------------------------- /docs/src/components/Logo.tsx: -------------------------------------------------------------------------------- 1 | import { HTMLAttributes } from 'react' 2 | import { addWithSpace } from '../utils/addWithSpace' 3 | 4 | const Logo = ({ className }: HTMLAttributes) => { 5 | return ( 6 | 14 | 20 | 30 | 31 | 39 | 40 | 41 | 42 | 50 | 51 | 52 | 53 | 54 | 55 | ) 56 | } 57 | 58 | export default Logo 59 | -------------------------------------------------------------------------------- /docs/src/components/PageBackground.tsx: -------------------------------------------------------------------------------- 1 | const PageBackground = () => { 2 | return ( 3 |
11 | ) 12 | } 13 | 14 | export default PageBackground 15 | -------------------------------------------------------------------------------- /docs/src/components/Paragraph.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react' 2 | 3 | const Paragraph = ({ 4 | children, 5 | className = '', 6 | size = 'regular', 7 | }: PropsWithChildren) => { 8 | return ( 9 |

20 | {children} 21 |

22 | ) 23 | } 24 | 25 | export interface ParagraphProps { 26 | children: PropsWithChildren 27 | className?: string 28 | size?: 'regular' | 'large' | 'small' 29 | } 30 | 31 | export default Paragraph 32 | -------------------------------------------------------------------------------- /docs/src/components/Separator.tsx: -------------------------------------------------------------------------------- 1 | const Separator = () =>
2 | 3 | export default Separator 4 | -------------------------------------------------------------------------------- /docs/src/components/Skeleton.tsx: -------------------------------------------------------------------------------- 1 | const Skeleton = ({ width = '200px' }) => { 2 | return ( 3 |
7 | ) 8 | } 9 | export default Skeleton 10 | -------------------------------------------------------------------------------- /docs/src/css/keyframes.css: -------------------------------------------------------------------------------- 1 | @keyframes bounce-bottom { 2 | 0% { 3 | transform: translateY(0); 4 | } 5 | 6 | 40% { 7 | transform: translateY(-3px); 8 | } 9 | 10 | 55% { 11 | transform: translateY(0); 12 | } 13 | 14 | 65% { 15 | transform: translateY(-2px); 16 | } 17 | 18 | 82% { 19 | transform: translateY(0); 20 | } 21 | 22 | 100% { 23 | transform: translateY(0); 24 | } 25 | } 26 | 27 | @keyframes appear { 28 | 0% { 29 | opacity: 0; 30 | transform: scale(0.5); 31 | } 32 | 33 | 40% { 34 | opacity: 1; 35 | transform: scale(1.1); 36 | } 37 | 38 | 60%, 39 | 100% { 40 | opacity: 1; 41 | transform: scale(1); 42 | } 43 | } 44 | 45 | @keyframes scale-to-right { 46 | to { 47 | width: 100%; 48 | } 49 | } 50 | 51 | @keyframes translate-down { 52 | to { 53 | transform: translateY(0); 54 | } 55 | } 56 | 57 | @keyframes translate-up { 58 | from { 59 | transform: translateY(0); 60 | } 61 | to { 62 | transform: translateY(-60%); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /docs/src/css/prism.css: -------------------------------------------------------------------------------- 1 | .function, 2 | .tag, 3 | .atrule { 4 | @apply text-fuchsia-700 dark:text-fuchsia-300; 5 | } 6 | 7 | .literal-property, 8 | .constant, 9 | .string-property, 10 | .attr-value, 11 | .property { 12 | @apply text-sky-700 dark:text-sky-300; 13 | } 14 | 15 | .operator, 16 | .comment { 17 | @apply opacity-60; 18 | } 19 | 20 | .punctuation { 21 | @apply opacity-40; 22 | } 23 | -------------------------------------------------------------------------------- /docs/src/demos/AppearDemo.tsx: -------------------------------------------------------------------------------- 1 | import Code from '../components/Code' 2 | import Skeleton from '../components/Skeleton' 3 | import DemoTriggerLine from './DemoTriggerLine' 4 | import DemoWrapper from './DemoWrapper' 5 | 6 | const skeletonCollection = [ 7 | '96%', 8 | '100%', 9 | '92%', 10 | '100%', 11 | '93%', 12 | '87%', 13 | '55%', 14 | '100%', 15 | '93%', 16 | '87%', 17 | '55%', 18 | ] 19 | 20 | const AppearDemo = () => { 21 | return ( 22 | 23 | 24 |
25 | {skeletonCollection.map((width, key) => ( 26 | 27 | ))} 28 |
29 | 30 |
31 | animate-appear timeline-view 32 |
33 | 34 |
35 | {skeletonCollection.map((width, key) => ( 36 | 37 | ))} 38 |
39 |
40 | ) 41 | } 42 | 43 | export default AppearDemo 44 | -------------------------------------------------------------------------------- /docs/src/demos/DemoPlaceholderContent.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react' 2 | import { addWithSpace } from '../utils/addWithSpace' 3 | 4 | const DemoPlaceholderContent = ({ 5 | children, 6 | className = '', 7 | }: PropsWithChildren) => { 8 | return ( 9 |
15 | {children} 16 |
17 | ) 18 | } 19 | 20 | export interface DemoWrapperProps { 21 | children: PropsWithChildren 22 | className?: string 23 | } 24 | 25 | export default DemoPlaceholderContent 26 | -------------------------------------------------------------------------------- /docs/src/demos/DemoTriggerLine.tsx: -------------------------------------------------------------------------------- 1 | import { addWithSpace } from '../utils/addWithSpace' 2 | 3 | const DemoTriggerLine = ({ 4 | className = '', 5 | explanation = '', 6 | percentage = undefined, 7 | }: DemoTriggerLineProps) => { 8 | return ( 9 |
10 |
11 | {percentage || explanation ? ( 12 |
13 | {percentage || percentage === 0 ? ( 14 | {percentage}% 15 | ) : ( 16 | '' 17 | )} 18 | {explanation ? {explanation} : ''} 19 |
20 | ) : ( 21 | '' 22 | )} 23 |
24 |
25 | ) 26 | } 27 | 28 | export interface DemoTriggerLineProps { 29 | percentage?: number | undefined 30 | className?: string 31 | explanation?: string 32 | iconClassName?: string 33 | } 34 | 35 | export default DemoTriggerLine 36 | -------------------------------------------------------------------------------- /docs/src/demos/DemoWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react' 2 | import { addWithSpace } from '../utils/addWithSpace' 3 | import ActionButton from '../components/ActionButton' 4 | import { Repeat, StepForward } from 'lucide-react' 5 | 6 | const replayButtonClick = (element: string) => { 7 | const wrapper = document.getElementById(element) 8 | wrapper && 9 | wrapper.getAnimations({ subtree: true }).forEach((anim) => { 10 | anim.cancel() 11 | anim.play() 12 | }) 13 | } 14 | 15 | const DemoWrapper = ({ 16 | children, 17 | className = '', 18 | actionButton = false, 19 | actionButtonElement = 'element', 20 | }: PropsWithChildren) => { 21 | return ( 22 |
29 | {actionButton && ( 30 |
31 | replayButtonClick(actionButtonElement)} 35 | tooltip="Replay" 36 | /> 37 |
38 | )} 39 | {children} 40 |
41 | ) 42 | } 43 | 44 | export interface DemoWrapperProps { 45 | children: PropsWithChildren 46 | className?: string 47 | actionButton?: boolean 48 | actionButtonElement?: string 49 | } 50 | 51 | export default DemoWrapper 52 | -------------------------------------------------------------------------------- /docs/src/demos/MultiRangeDemo.tsx: -------------------------------------------------------------------------------- 1 | import Code from '../components/Code' 2 | import DemoAnimationExampleRow from '../components/DemoAnimationExampleRow' 3 | import Skeleton from '../components/Skeleton' 4 | import DemoWrapper from './DemoWrapper' 5 | import DemoTriggerLine from './DemoTriggerLine' 6 | 7 | const skeletonCollection = [ 8 | '46%', 9 | '100%', 10 | '92%', 11 | '100%', 12 | '93%', 13 | '87%', 14 | '55%', 15 | '100%', 16 | '93%', 17 | '87%', 18 | '55%', 19 | ] 20 | 21 | const MultiRangeDemo = () => { 22 | return ( 23 | 24 |
25 | 26 | 27 | 28 | 29 |
30 |
31 |
32 | {skeletonCollection.map((width, key) => ( 33 | 34 | ))} 35 |
36 | 37 |
38 | view-timeline/demo 39 |
40 | 41 |
42 | {skeletonCollection.map((width, key) => ( 43 | 44 | ))} 45 |
46 |
47 |
48 | ) 49 | } 50 | 51 | export default MultiRangeDemo 52 | -------------------------------------------------------------------------------- /docs/src/demos/ProgressBarDemo.tsx: -------------------------------------------------------------------------------- 1 | import Code from '../components/Code' 2 | import Skeleton from '../components/Skeleton' 3 | import DemoTriggerLine from './DemoTriggerLine' 4 | import DemoWrapper from './DemoWrapper' 5 | 6 | const skeletonCollection = ['96%', '100%', '92%', '100%', '93%', '87%', '55%'] 7 | 8 | const ProgressBarDemo = () => { 9 | return ( 10 | 11 |
12 | animate-scale-to-right timeline 13 |
14 |
15 | 21 | 22 |
23 | {skeletonCollection.map((width, key) => ( 24 | 25 | ))} 26 |
27 | {skeletonCollection.map((width, key) => ( 28 | 29 | ))} 30 | 36 |
37 |
38 | ) 39 | } 40 | 41 | export default ProgressBarDemo 42 | -------------------------------------------------------------------------------- /docs/src/demos/RangeDemo.tsx: -------------------------------------------------------------------------------- 1 | import Code from '../components/Code' 2 | import Skeleton from '../components/Skeleton' 3 | import DemoPlaceholderContent from './DemoPlaceholderContent' 4 | import DemoTriggerLine from './DemoTriggerLine' 5 | import DemoWrapper from './DemoWrapper' 6 | 7 | const skeletonCollection = ['96%', '100%', '92%', '100%', '93%', '87%', '55%'] 8 | 9 | const RangeDemo = () => { 10 | return ( 11 | 12 |
13 |
14 | animate... 15 | timeline/navbar 16 | range-on-exit 17 |
18 |
19 | scope/navbar 20 |
21 | 22 | 23 | 24 | 25 | 30 |
31 | view-timeline/navbar 32 |
33 | 38 |
39 | {skeletonCollection.map((width, key) => ( 40 | 41 | ))} 42 |
43 | {skeletonCollection.map((width, key) => ( 44 | 45 | ))} 46 |
47 |
48 |
49 | ) 50 | } 51 | 52 | export default RangeDemo 53 | -------------------------------------------------------------------------------- /docs/src/demos/SupportsDemo.tsx: -------------------------------------------------------------------------------- 1 | import Code from '../components/Code' 2 | import Skeleton from '../components/Skeleton' 3 | import DemoWrapper from './DemoWrapper' 4 | 5 | const skeletonCollection = [ 6 | '96%', 7 | '100%', 8 | '92%', 9 | '100%', 10 | '93%', 11 | '75%', 12 | '32%', 13 | '96%', 14 | '100%', 15 | '92%', 16 | '100%', 17 | '93%', 18 | '75%', 19 | '32%', 20 | ] 21 | 22 | const SupportsDemo = () => { 23 | return ( 24 | 25 |
26 | animate... 27 | timeline 28 | -translate-y-20 29 | no-animations:translate-y-0 30 |
31 |
32 | 33 | {skeletonCollection.map((width, key) => ( 34 | 35 | ))} 36 |
37 |
38 | ) 39 | } 40 | 41 | export default SupportsDemo 42 | -------------------------------------------------------------------------------- /docs/src/demos/TimelineOverrideDemo.tsx: -------------------------------------------------------------------------------- 1 | import Code from '../components/Code' 2 | import Skeleton from '../components/Skeleton' 3 | import DemoWrapper from './DemoWrapper' 4 | 5 | const skeletonCollection = ['46%', '100%', '92%', '100%', '93%', '87%', '93%', '87%', '55%'] 6 | 7 | const TimelineOverrideDemo = () => { 8 | return ( 9 | 10 |
11 |
12 | 13 | timeline before{' '} 14 | animate-translate-down 15 | 16 |
17 |
18 | 19 | animate-translate-down before{' '} 20 | timeline 21 | 22 |
23 |
24 |
25 | {skeletonCollection.map((width, key) => ( 26 | 27 | ))} 28 |
29 |
30 | {skeletonCollection.map((width, key) => ( 31 | 32 | ))} 33 |
34 |
35 | ) 36 | } 37 | 38 | export default TimelineOverrideDemo 39 | -------------------------------------------------------------------------------- /docs/src/docs/Docs.tsx: -------------------------------------------------------------------------------- 1 | import { Minus } from 'lucide-react' 2 | import Code from '../components/Code' 3 | import CodeBlock from '../components/CodeBlock' 4 | import Heading from '../components/Heading' 5 | import Paragraph from '../components/Paragraph' 6 | import MultiRangeDemo from '../demos/MultiRangeDemo' 7 | import SupportsDemo from '../demos/SupportsDemo' 8 | import { 9 | appearDemo, 10 | appearKeyframes, 11 | multiRange, 12 | multiRangeKeyframes, 13 | progressBarDemo, 14 | progressBarKeyframes, 15 | rangeDemo, 16 | rangeKeyframes, 17 | supports, 18 | } from '../utils/demoExamples' 19 | import DocsTable from './DocsTable' 20 | import ProgressBarDemo from '../demos/ProgressBarDemo' 21 | import AppearDemo from '../demos/AppearDemo' 22 | import RangeDemo from '../demos/RangeDemo' 23 | 24 | const animationTimelineClasses = [ 25 | { className: 'timeline', code: 'animation-timeline: scroll(y)' }, 26 | { className: 'timeline-scroll-x', code: 'animation-timeline: scroll(x)' }, 27 | { className: 'timeline-view', code: 'animation-timeline: view()' }, 28 | { className: 'timeline-auto', code: 'animation-timeline: auto' }, 29 | { className: 'timeline-none', code: 'animation-timeline: none' }, 30 | { className: 'timeline/{name}', code: 'animation-timeline: --{name}' }, 31 | ] 32 | 33 | const scopeClasses = [{ className: 'scope/{name}', code: 'timeline-scope: --{name}' }] 34 | 35 | const rangeClasses = [ 36 | { className: 'range', code: 'animation-range: cover 0% cover 100%' }, 37 | { className: 'range-contain', code: 'animation-range: contain 0% contain 100%' }, 38 | { className: 'range-on-entry', code: 'animation-range: entry 0% entry 100%' }, 39 | { className: 'range-on-exit', code: 'animation-range: exit 0% exit 100%' }, 40 | { 41 | className: 'range/{startValue}_{endValue}', 42 | code: 'animation-range: cover {value} cover {endValue}', 43 | }, 44 | ] 45 | 46 | const scrollTimelineClasses = [ 47 | { className: 'scroll-timeline/{name}', code: 'scroll-timeline: --{name} y' }, 48 | { className: 'scroll-timeline-x/{name}', code: 'scroll-timeline: --{name} x' }, 49 | { className: 'scroll-timeline-block/{name}', code: 'scroll-timeline: --{name} block' }, 50 | ] 51 | 52 | const viewTimelineClasses = [ 53 | { className: 'view-timeline/{name}', code: 'view-timeline: --{name} y' }, 54 | { className: 'view-timeline-x/{name}', code: 'view-timeline: --{name} x' }, 55 | { className: 'view-timeline-block/{name}', code: 'view-timeline: --{name} block' }, 56 | ] 57 | 58 | const supportsClasses = [ 59 | { className: 'no-animations:...', code: '@supports not (animation-range: cover) { ... }' }, 60 | ] 61 | 62 | const Docs = () => { 63 | return ( 64 |
65 | 66 | Documentation 67 | 68 | 69 | The plugin provides utilities for a subset of CSS scroll-driven animation properties: 70 | 71 |
    72 |
  • 73 | 74 | animation-timeline 75 |
  • 76 |
  • 77 | 78 | scroll-timeline, view-timeline 79 |
  • 80 |
  • 81 | 82 | animation-range 83 |
  • 84 |
  • 85 | 86 | timeline-scope 87 |
  • 88 |
89 | 90 | 91 | Animation Timeline 92 | 93 | 94 | The single most impressive feature of scroll-driven animations is an anonymous animation 95 | timeline. It allows to easily trigger anything just by scrolling the page. Use the{' '} 96 | timeline utility which defaults to animation-timeline: scroll(y){' '} 97 | and also provides an option to set custom timeline name with a modifier. 98 | 99 | 100 | Anonymous Scroll Timeline Demo 101 | 102 | This demo showcases how to create a simple progress bar just by adding one utility class to 103 | the element. We define the anonymous scroll timeline by adding timeline to the 104 | progress bar. 105 | 106 | 107 | {progressBarDemo} 108 | {progressBarKeyframes} 109 | Anonymous View Timeline Demo 110 | 111 | This demo showcases how to make the element appear after entering the view frame. We define 112 | the anonymous view timeline by adding timeline-view to this element. 113 | 114 | 115 | {appearDemo} 116 | {appearKeyframes} 117 | 118 | Named Timelines 119 | 120 | Scroll Timeline 121 | 122 | 123 | Utility class setting the named scroll progress timeline, which is set on a scrollable 124 | element. 125 | 126 | 127 | 128 | View Timeline 129 | 130 | 131 | Utility class setting the named view progress timeline, which is set on a subject inside 132 | another scrollable element. 133 | 134 | 135 | 136 | Animation Range 137 | 138 | 139 | Animation range start controls where along the timeline an animation will start. It is set 140 | on the animated element. 141 | 142 | 143 | Animation Range Demo 144 | 145 | Scroll the container to see each how range utility class affects the animation. 146 | 147 | 148 | {multiRange} 149 | {multiRangeKeyframes} 150 | 151 | Timeline Scope 152 | 153 | 154 | Timeline scope allows to control animations outside the element which defines the timeline. 155 | 156 | 157 | Range, Scope and Animation Timeline Name Demo 158 | 159 | This demo showcases the usage of the plugin to reveal the navigation bar. The{' '} 160 | view-timeline/navbar utility sets up the animation timeline, which is then 161 | scoped out of the defining element by scope/navbar. The navigation bar is 162 | controlled by this timeline with the timeline/navbar utility. Utility class{' '} 163 | range-on-exit is set to limit the timeline duration. 164 | 165 | 166 | {rangeDemo} 167 | {rangeKeyframes} 168 | 169 | Fallback Styling 170 | 171 | 172 | Use the no-animations modifier to apply fallback styling in browsers which do 173 | not support scroll-driven animations yet. 174 | 175 | 176 | Fallback Demo 177 | 178 | {supports} 179 |
180 | ) 181 | } 182 | 183 | export default Docs 184 | -------------------------------------------------------------------------------- /docs/src/docs/DocsTable.tsx: -------------------------------------------------------------------------------- 1 | import Separator from '../components/Separator' 2 | import DocsTableRow from './DocsTableRow' 3 | 4 | const DocsTable = ({ items }: DocsTableProps) => { 5 | return ( 6 |
7 |
8 |
Class
9 |
/
10 |
Code
11 |
12 | 13 | {items.map((item, index) => ( 14 | <> 15 | 16 | 17 | 18 | ))} 19 |
20 | ) 21 | } 22 | 23 | export interface DocsTableProps { 24 | items: Item[] 25 | } 26 | 27 | export interface Item { 28 | className: string 29 | code: string 30 | } 31 | 32 | export default DocsTable 33 | -------------------------------------------------------------------------------- /docs/src/docs/DocsTableRow.tsx: -------------------------------------------------------------------------------- 1 | const DocsTableRow = ({ className, code }: DocsTableRowProps) => { 2 | return ( 3 |
4 |
{className}
5 |
{code};
6 |
7 | ) 8 | } 9 | 10 | export interface DocsTableRowProps { 11 | className: string 12 | code: string 13 | } 14 | 15 | export default DocsTableRow 16 | -------------------------------------------------------------------------------- /docs/src/fonts/inter-variable.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamplesnik/tailwindcss-scroll-driven-animations/HEAD/docs/src/fonts/inter-variable.woff2 -------------------------------------------------------------------------------- /docs/src/fonts/inter.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-display: block; 3 | font-family: 'Inter'; 4 | font-style: normal; 5 | font-weight: 100 900; 6 | src: url('./inter-variable.woff2') format('woff2'); 7 | } 8 | -------------------------------------------------------------------------------- /docs/src/index.css: -------------------------------------------------------------------------------- 1 | @import 'fonts/inter.css'; 2 | @import 'css/keyframes.css'; 3 | @import 'css/prism.css'; 4 | 5 | @tailwind base; 6 | @tailwind components; 7 | @tailwind utilities; 8 | -------------------------------------------------------------------------------- /docs/src/layouts/Page.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react' 2 | import { ScrollRestoration } from 'react-router-dom' 3 | import PageBackground from '../components/PageBackground' 4 | import Nav from '../partials/Nav' 5 | import Footer from '../partials/Footer' 6 | import { Analytics } from '@vercel/analytics/react' 7 | 8 | function Page({ children }: PropsWithChildren) { 9 | return ( 10 |
11 | 12 |
20 | ) 21 | } 22 | 23 | export interface PageProps { 24 | children: PropsWithChildren 25 | } 26 | 27 | export default Page 28 | -------------------------------------------------------------------------------- /docs/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import { RouterProvider, createBrowserRouter } from 'react-router-dom' 4 | import './index.css' 5 | import DocsView from './views/DocsView' 6 | import TechView from './views/TechView' 7 | import HowToView from './views/HowToView' 8 | 9 | const router = createBrowserRouter([ 10 | { 11 | path: '/', 12 | element: , 13 | errorElement: , 14 | }, 15 | { 16 | path: '/docs', 17 | element: , 18 | }, 19 | { 20 | path: '/tech', 21 | element: , 22 | }, 23 | ]) 24 | 25 | ReactDOM.createRoot(document.getElementById('root')!).render( 26 | 27 | 28 | 29 | ) 30 | -------------------------------------------------------------------------------- /docs/src/partials/Animations.tsx: -------------------------------------------------------------------------------- 1 | import { Github } from 'lucide-react' 2 | import CodeBlock from '../components/CodeBlock.tsx' 3 | import Heading from '../components/Heading.tsx' 4 | import { 5 | codeExampleRange, 6 | codeExampleScope, 7 | codeExampleSupports, 8 | codeExampleTimeline, 9 | codeExampleView, 10 | } from '../utils/codeExamples.ts' 11 | 12 | const Animations = () => { 13 | return ( 14 | <> 15 | 16 | Tech 17 | 18 | 19 | Animation Timeline 20 | 21 | 28 | {codeExampleTimeline} 29 | 30 | 31 | Scroll and View Timeline 32 | 33 | 40 | {codeExampleView} 41 | 42 | 43 | Range 44 | 45 | 52 | {codeExampleRange} 53 | 54 | 55 | Scope 56 | 57 | 64 | {codeExampleScope} 65 | 66 | 67 | Fallback Styling 68 | 69 | 76 | {codeExampleSupports} 77 | 78 | 79 | ) 80 | } 81 | 82 | export default Animations 83 | -------------------------------------------------------------------------------- /docs/src/partials/Footer.tsx: -------------------------------------------------------------------------------- 1 | import Link from '../components/Link' 2 | 3 | const Footer = () => { 4 | return ( 5 |
6 | Created by Adam Plesník, Bratislava, Slovakia 7 |
8 | 13 | github.com/tailwindcss-scroll-driven-animations 14 | 15 | 16 | adamplesnik.com 17 | 18 |
19 |
20 | ) 21 | } 22 | 23 | export default Footer 24 | -------------------------------------------------------------------------------- /docs/src/partials/Installation.tsx: -------------------------------------------------------------------------------- 1 | import CodeBlock from '../components/CodeBlock' 2 | import Heading from '../components/Heading' 3 | import Paragraph from '../components/Paragraph' 4 | 5 | const plugin = `module.exports = { 6 | plugins: [ 7 | require('@adam.plesnik/tailwindcss-scroll-driven-animations'), 8 | // ... 9 | ], 10 | }` 11 | 12 | const Installation = () => { 13 | return ( 14 |
15 | Installation 16 | Install the plugin from npm. 17 | 18 | npm install @adam.plesnik/tailwindcss-scroll-driven-animations 19 | 20 | Then add the plugin to your tailwind.config.js. 21 | {plugin} 22 |
23 | ) 24 | } 25 | 26 | export default Installation 27 | -------------------------------------------------------------------------------- /docs/src/partials/MainTitle.tsx: -------------------------------------------------------------------------------- 1 | import Heading from '../components/Heading.tsx' 2 | 3 | const MainTitle = () => { 4 | return Scroll-driven Animations for Tailwind CSS 5 | } 6 | 7 | export default MainTitle 8 | -------------------------------------------------------------------------------- /docs/src/partials/Me.tsx: -------------------------------------------------------------------------------- 1 | import { TrendingUp } from 'lucide-react' 2 | import Heading from '../components/Heading.tsx' 3 | import Link from '../components/Link.tsx' 4 | import Paragraph from '../components/Paragraph.tsx' 5 | 6 | const Me = () => { 7 | return ( 8 | <> 9 | 10 | Me 11 | 12 | 13 | I am married, 38 years old, father of two kids, living in Bratislava, Slovakia. While my 14 | passion for coding is obvious, I also enjoy mountain biking, traveling, and spending quality 15 | time with my family. 16 | 17 | 18 | I speak English and French fluently, and because I love Portugal, I'm also learning 19 | Portuguese. 20 | 21 | 22 | Learn more about me at my{' '} 23 | 24 | personal page 25 | 26 | . 27 | 28 | 29 | ) 30 | } 31 | 32 | export default Me 33 | -------------------------------------------------------------------------------- /docs/src/partials/Nav.tsx: -------------------------------------------------------------------------------- 1 | import { Github } from 'lucide-react' 2 | import DarkModeSwitch from '../components/DarkModeSwitch.tsx' 3 | import HeaderNavAnchor from '../components/HeaderNavAnchor.tsx' 4 | import Logo from '../components/Logo.tsx' 5 | import { NavLink } from 'react-router-dom' 6 | 7 | const Nav = () => { 8 | return ( 9 |
10 |
11 | 15 | 16 | scrolldriven.dev 17 | 18 | Docs 19 | Tech 20 | Showcase ↗ 21 |
22 | 23 | 27 | 28 | 29 |
30 |
31 |
32 | ) 33 | } 34 | 35 | export default Nav 36 | -------------------------------------------------------------------------------- /docs/src/utils/addWithSpace.ts: -------------------------------------------------------------------------------- 1 | export const addWithSpace = (value: string | undefined) => { 2 | return value ? ` ${value}` : '' 3 | } 4 | -------------------------------------------------------------------------------- /docs/src/utils/codeExamples.ts: -------------------------------------------------------------------------------- 1 | export const codeExampleTimeline = `matchUtilities( 2 | { 3 | timeline: (value, { modifier }) => ({ 4 | 'animation-timeline': modifier ? \`--\${modifier}\` : value, 5 | }), 6 | }, 7 | { 8 | values: { 9 | DEFAULT: 'scroll(y)', 10 | auto: 'auto', 11 | none: 'none', 12 | 'scroll-x': 'scroll(x)', 13 | view: 'view()', 14 | }, 15 | modifiers: 'any', 16 | } 17 | )` 18 | 19 | export const codeExampleView = `'view-timeline': (value, { modifier }) => ({ 20 | viewTimeline: (modifier ? \`--\${modifier} \` : 'none ') + value, 21 | }),` 22 | 23 | export const codeExampleRange = `matchUtilities( 24 | { 25 | range: (value, { modifier }) => ({ 26 | animationRange: splitAndCombine(value, modifier), 27 | }), 28 | }, 29 | { 30 | values: { 31 | DEFAULT: 'cover cover', 32 | 'on-entry': 'entry entry', 33 | 'on-exit': 'exit exit', 34 | contain: 'contain contain', 35 | }, 36 | modifiers: 'any', 37 | } 38 | )` 39 | 40 | export const codeExampleScope = `scope: (value, { modifier }) => ({ 41 | timelineScope: \`--\${modifier}\`, 42 | }),` 43 | 44 | export const codeExampleSupports = `addVariant('no-animations', '@supports not (animation-range: cover)')` 45 | -------------------------------------------------------------------------------- /docs/src/utils/demoExamples.ts: -------------------------------------------------------------------------------- 1 | export const progressBarDemo = `
2 |
3 | 4 |
5 |
6 | ... 7 |
8 |
` 9 | 10 | export const progressBarKeyframes = `@keyframes scale-to-right { 11 | to { 12 | width: 100%; 13 | } 14 | }` 15 | 16 | export const rangeDemo = `
17 | 18 |
19 | 20 |
21 |
22 | ... 23 |
24 | 25 |
26 | ... 27 |
28 |
` 29 | 30 | export const rangeKeyframes = `@keyframes translate-down { 31 | to { 32 | transform: translateY(0); 33 | } 34 | }` 35 | 36 | export const appearDemo = `
37 | ... 38 |
39 | 40 |
41 | ... 42 |
` 43 | 44 | export const appearKeyframes = `@keyframes appear { 45 | 0% { 46 | opacity: 0; 47 | transform: scale(0.5); 48 | } 49 | 50 | 40% { 51 | opacity: 1; 52 | transform: scale(1.1); 53 | } 54 | 55 | 60%, 56 | 100% { 57 | opacity: 1; 58 | transform: scale(1); 59 | } 60 | }` 61 | 62 | export const multiRange = `
63 | 64 |
65 | 66 |
67 | ... 68 |
69 | 70 |
71 |
` 72 | 73 | export const multiRangeKeyframes = `@keyframes scale-to-right { 74 | to { 75 | width: 100%; 76 | } 77 | }` 78 | 79 | export const supports = `
80 |
81 | 82 |
83 | ... 84 |
` 85 | 86 | export const keyframes101 = `@keyframes translate-down { 87 | to { 88 | transform: translateY(0); 89 | } 90 | }` 91 | 92 | export const keyframes102 = `
93 | 94 |
` 95 | 96 | export const keyframes103 = ` 97 | .timeline { /* This timeline would be overriden */ 98 | animation-timeline: scroll(y); 99 | } 100 | 101 | .animate-translate-down { 102 | animation: translate-down 3s cubic-bezier(0.65, 0.05, 0.17, 0.99) forwards; 103 | } 104 | 105 | .timeline { /* This is the correct order */ 106 | animation-timeline: scroll(y); 107 | }` 108 | -------------------------------------------------------------------------------- /docs/src/views/DocsView.tsx: -------------------------------------------------------------------------------- 1 | import { WandSparkles } from 'lucide-react' 2 | import Docs from '../docs/Docs' 3 | import Page from '../layouts/Page' 4 | 5 | const DocsView = () => { 6 | return ( 7 | 8 |
9 | 13 | Scroll-driven animations are not yet supported by your browser. Use Chrome 116 or newer to 14 | see the demos working correctly. 15 |
16 | 17 |
18 | ) 19 | } 20 | 21 | export default DocsView 22 | -------------------------------------------------------------------------------- /docs/src/views/HowToView.tsx: -------------------------------------------------------------------------------- 1 | import Code from '../components/Code' 2 | import CodeBlock from '../components/CodeBlock' 3 | import Heading from '../components/Heading' 4 | import Paragraph from '../components/Paragraph' 5 | import TimelineOverrideDemo from '../demos/TimelineOverrideDemo' 6 | import Page from '../layouts/Page' 7 | import Installation from '../partials/Installation' 8 | import MainTitle from '../partials/MainTitle' 9 | import { keyframes101, keyframes102, keyframes103 } from '../utils/demoExamples' 10 | 11 | const HowToView = () => { 12 | return ( 13 | 14 | 15 | 16 | Unofficial and experimental plugin for Tailwind CSS v3.4+ that provides utilities for 17 | scroll-driven animations. 18 | 19 | 20 | 21 | How to Make Your CSS Animation Scroll-driven 22 | 23 | 24 | CSS animations consist of two components, a set of keyframes and a style describing the 25 | animation. Let's declare a simple translate-down keyframe set and apply it to 26 | an element we want to control by a scroll timeline. 27 | 28 | {keyframes101} 29 | {keyframes102} 30 | 31 | To effectively control the animation, make sure to declare the timeline in the code after 32 | the animation. By default, the shorthand animation property sets the{' '} 33 | animation-timeline: auto unless set otherwise. However, using this plugin and 34 | Tailwind CSS animations ensures that the declaration order is correct. 35 | 36 | {keyframes103} 37 | Scroll the container. 38 | 39 | 40 | ) 41 | } 42 | 43 | export default HowToView 44 | -------------------------------------------------------------------------------- /docs/src/views/TechView.tsx: -------------------------------------------------------------------------------- 1 | import Page from '../layouts/Page.tsx' 2 | import Animations from '../partials/Animations.tsx' 3 | 4 | function TechView() { 5 | return ( 6 | 7 | 8 | 9 | ) 10 | } 11 | 12 | export default TechView 13 | -------------------------------------------------------------------------------- /docs/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /docs/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | const defaultTheme = require('tailwindcss/defaultTheme') 3 | import plugin from 'tailwindcss/plugin' 4 | 5 | module.exports = { 6 | darkMode: 'selector', 7 | content: ['./src/**/*.{js,ts,jsx,tsx}', './index.html'], 8 | theme: { 9 | extend: { 10 | fontFamily: { 11 | sans: ['Inter', ...defaultTheme.fontFamily.sans], 12 | }, 13 | animation: { 14 | /* Regular CSS animations */ 15 | 'bounce-bottom': 'bounce-bottom 550ms ease-in-out 220ms', 16 | 17 | /* Scroll-driven animations */ 18 | appear: 'appear auto cubic-bezier(0.65, 0.05, 0.17, 0.99) forwards', 19 | opacity: 'opacity 2s cubic-bezier(0.65, 0.05, 0.17, 0.99) forwards', 20 | 'scale-to-right': 'scale-to-right auto linear forwards', 21 | 'to-stroke-dashoffset-0': 'to-stroke-dashoffset-0 5s ease-in-out forwards', 22 | 'translate-down': 'translate-down 3s cubic-bezier(0.65, 0.05, 0.17, 0.99) forwards', 23 | 'translate-up': 'translate-up auto ease-in-out forwards', 24 | }, 25 | transitionTimingFunction: { 26 | bounce: 'cubic-bezier(0.26, 0.53, 1, 0.63)', 27 | line: 'cubic-bezier(0.65, 0.05, 0.17, 0.99)', 28 | }, 29 | keyframes: { 30 | 'to-stroke-dashoffset-0': { 31 | to: { strokeDashoffset: 0 }, 32 | }, 33 | }, 34 | }, 35 | }, 36 | plugins: [ 37 | require('@adam.plesnik/tailwindcss-scroll-driven-animations'), 38 | plugin(function ({ matchUtilities, addVariant }) { 39 | matchUtilities( 40 | { 41 | 'dash-offset': (value, { modifier }) => ({ 42 | strokeDashoffset: modifier, 43 | }), 44 | }, 45 | { values: { DEFAULT: '' }, modifiers: 'any' } 46 | ) 47 | matchUtilities( 48 | { 49 | 'dash-array': (value, { modifier }) => ({ 50 | strokeDasharray: modifier, 51 | }), 52 | }, 53 | { values: { DEFAULT: '' }, modifiers: 'any' } 54 | ) 55 | addVariant('path', '& > path') 56 | }), 57 | ], 58 | } 59 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 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 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /docs/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /docs/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] 3 | } 4 | -------------------------------------------------------------------------------- /docs/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [ 7 | react({ 8 | babel: { 9 | plugins: [ 10 | [ 11 | 'prismjs', 12 | { 13 | languages: ['js', 'css', 'html', 'tsx', 'bash'], 14 | }, 15 | ], 16 | ], 17 | }, 18 | }), 19 | ], 20 | }) 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@adam.plesnik/tailwindcss-scroll-driven-animations", 3 | "version": "0.3.0", 4 | "author": "Adam Plesnik ", 5 | "scripts": { 6 | "dev-docs": "npm run --workspace=docs dev", 7 | "build": "swc ./src/index.ts --out-dir ./dist", 8 | "test": "vitest" 9 | }, 10 | "workspaces": [ 11 | "docs" 12 | ], 13 | "prettier": { 14 | "printWidth": 100, 15 | "semi": false, 16 | "singleQuote": true, 17 | "trailingComma": "es5" 18 | }, 19 | "peerDependencies": { 20 | "tailwindcss": ">=3.4.0" 21 | }, 22 | "devDependencies": { 23 | "@swc/cli": "^0.6.0", 24 | "@swc/core": "^1.4.11", 25 | "@types/jest": "^29.5.12", 26 | "prettier": "^3.2.5", 27 | "swcify": "^1.0.1", 28 | "tailwindcss": "^0.0.0-insiders.3ba51d1", 29 | "typescript": "^5.4.3", 30 | "vitest": "^1.5.0" 31 | }, 32 | "description": "A plugin for Tailwind CSS v3.4+ that provides utilities for scroll-driven animations.", 33 | "files": [ 34 | "dist" 35 | ], 36 | "main": "dist/src/index.js", 37 | "repository": { 38 | "type": "git", 39 | "url": "git+https://github.com/adamplesnik/tailwindcss-scroll-driven-animations.git" 40 | }, 41 | "keywords": [ 42 | "tailwindcss", 43 | "scroll", 44 | "driven", 45 | "animations", 46 | "css", 47 | "tailwind" 48 | ], 49 | "license": "MIT", 50 | "bugs": { 51 | "url": "https://github.com/adamplesnik/tailwindcss-scroll-driven-animations/issues" 52 | }, 53 | "homepage": "https://tailwind.adamplesnik.com" 54 | } 55 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import plugin from 'tailwindcss/plugin' 2 | 3 | export = plugin( 4 | function scrollDrivenAnimations({ matchUtilities, addVariant, theme }) { 5 | matchUtilities( 6 | { 7 | timeline: (value, { modifier }) => ({ 8 | animationTimeline: modifier ? `--${modifier}` : value, 9 | }), 10 | }, 11 | { 12 | values: { 13 | DEFAULT: 'scroll(y)', 14 | auto: 'auto', 15 | none: 'none', 16 | 'scroll-x': 'scroll(x)', 17 | view: 'view()', 18 | }, 19 | modifiers: 'any', 20 | } 21 | ) 22 | 23 | matchUtilities( 24 | { 25 | 'scroll-timeline': (value, { modifier }) => ({ 26 | scrollTimeline: (modifier ? `--${modifier} ` : 'none ') + value, 27 | }), 28 | }, 29 | { 30 | values: theme('timelineValues'), 31 | modifiers: 'any', 32 | } 33 | ) 34 | 35 | matchUtilities( 36 | { 37 | 'view-timeline': (value, { modifier }) => ({ 38 | viewTimeline: (modifier ? `--${modifier} ` : 'none ') + value, 39 | }), 40 | }, 41 | { 42 | values: theme('timelineValues'), 43 | modifiers: 'any', 44 | } 45 | ) 46 | 47 | matchUtilities( 48 | { 49 | scope: (value, { modifier }) => ({ 50 | timelineScope: `--${modifier}`, 51 | }), 52 | }, 53 | { values: { DEFAULT: '' }, modifiers: 'any' } 54 | ) 55 | 56 | matchUtilities( 57 | { 58 | range: (value, { modifier }) => ({ 59 | animationRange: splitAndCombine(value, modifier), 60 | }), 61 | }, 62 | { 63 | values: { 64 | DEFAULT: 'cover cover', 65 | 'on-entry': 'entry entry', 66 | 'on-exit': 'exit exit', 67 | contain: 'contain contain', 68 | }, 69 | modifiers: 'any', 70 | } 71 | ) 72 | 73 | addVariant('no-animations', '@supports not (animation-range: cover)') 74 | }, 75 | 76 | { 77 | theme: { 78 | timelineValues: { 79 | DEFAULT: 'y', 80 | block: 'block', 81 | x: 'x', 82 | }, 83 | }, 84 | } 85 | ) 86 | 87 | function splitAndCombine(values: string, modifiers: string | null) { 88 | const valueArray = (values || '').split(' ') 89 | const modifierArray = (modifiers || ['0_100%'].join('_')).split('_') 90 | 91 | const combinedValues = [valueArray[0], modifierArray[0], valueArray[1], modifierArray[1]] 92 | 93 | return combinedValues.join(' ') 94 | } 95 | -------------------------------------------------------------------------------- /tests/content.ts: -------------------------------------------------------------------------------- 1 | export const contentToTest = String.raw` 2 |
3 |
4 |
5 |
6 |
7 |
8 | 9 |
10 |
11 |
12 |
13 |
14 |
15 | 16 |
17 |
18 |
19 |
20 |
21 |
22 | 23 |
24 | 25 |
26 |
27 |
28 |
29 | 30 |
31 |
32 |
33 |
34 | 35 |
36 | ` 37 | -------------------------------------------------------------------------------- /tests/expect.ts: -------------------------------------------------------------------------------- 1 | export const expectedCss = String.raw` 2 | .timeline { 3 | animation-timeline: scroll(y) 4 | } 5 | .timeline-auto { 6 | animation-timeline: auto 7 | } 8 | .timeline-none { 9 | animation-timeline: none 10 | } 11 | .timeline-scroll-x { 12 | animation-timeline: scroll(x) 13 | } 14 | .timeline-view { 15 | animation-timeline: view() 16 | } 17 | .timeline\/test { 18 | animation-timeline: --test 19 | } 20 | .scroll-timeline { 21 | scroll-timeline: none y 22 | } 23 | .scroll-timeline-block { 24 | scroll-timeline: none block 25 | } 26 | .scroll-timeline-block\/test { 27 | scroll-timeline: --test block 28 | } 29 | .scroll-timeline-x { 30 | scroll-timeline: none x 31 | } 32 | .scroll-timeline-x\/test { 33 | scroll-timeline: --test x 34 | } 35 | .scroll-timeline\/test { 36 | scroll-timeline: --test y 37 | } 38 | .view-timeline { 39 | view-timeline: none y 40 | } 41 | .view-timeline-block { 42 | view-timeline: none block 43 | } 44 | .view-timeline-block\/test { 45 | view-timeline: --test block 46 | } 47 | .view-timeline-x { 48 | view-timeline: none x 49 | } 50 | .view-timeline-x\/test { 51 | view-timeline: --test x 52 | } 53 | .view-timeline\/test { 54 | view-timeline: --test y 55 | } 56 | .scope\/test { 57 | timeline-scope: --test 58 | } 59 | .range { 60 | animation-range: cover 0 cover 100% 61 | } 62 | .range-contain { 63 | animation-range: contain 0 contain 100% 64 | } 65 | .range-contain\/10px_100px { 66 | animation-range: contain 10px contain 100px 67 | } 68 | .range-on-entry { 69 | animation-range: entry 0 entry 100% 70 | } 71 | .range-on-entry\/10px_100px { 72 | animation-range: entry 10px entry 100px 73 | } 74 | .range-on-exit { 75 | animation-range: exit 0 exit 100% 76 | } 77 | .range-on-exit\/10px_100px { 78 | animation-range: exit 10px exit 100px 79 | } 80 | .range\/10px_100px { 81 | animation-range: cover 10px cover 100px 82 | } 83 | @supports not (animation-range: cover) { 84 | .no-animations\:px-0 { 85 | padding-left: 0px; 86 | padding-right: 0px 87 | } 88 | }` 89 | -------------------------------------------------------------------------------- /tests/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { expectedCss } from './expect' 3 | import { run, strip } from './utils' 4 | import { contentToTest } from './content' 5 | 6 | test('Test all util combinations and @support', async () => { 7 | let config = { 8 | content: [ 9 | { 10 | raw: contentToTest, 11 | }, 12 | ], 13 | theme: {}, 14 | corePlugins: { preflight: false }, 15 | } 16 | 17 | let input = String.raw` 18 | @tailwind utilities; 19 | ` 20 | 21 | const result = await run(input, config) 22 | expect(strip(result.css)).toEqual(strip(expectedCss)) 23 | }) 24 | -------------------------------------------------------------------------------- /tests/utils.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import postcss from 'postcss' 3 | import tailwind, { Config } from 'tailwindcss' 4 | import { expect } from 'vitest' 5 | import scrollDrivenAnimations from '../src/index' 6 | 7 | export function run(input: string, config: Config, plugin = tailwind) { 8 | let { currentTestName } = expect.getState() 9 | 10 | config.plugins ??= [] 11 | if (!config.plugins.includes(scrollDrivenAnimations)) { 12 | config.plugins.push(scrollDrivenAnimations) 13 | } 14 | 15 | return postcss(plugin(config)).process(input, { 16 | from: `${path.resolve(__filename)}?test=${currentTestName}`, 17 | }) 18 | } 19 | 20 | export function strip(str: string) { 21 | return str.replace(/\s/g, '').replace(/;/g, '') 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "declaration": true, 5 | "declarationDir": "dist", 6 | "declarationMap": false, 7 | "esModuleInterop": true, 8 | "lib": ["ES2020"], 9 | "module": "CommonJS", 10 | "moduleResolution": "node", 11 | "outDir": "dist", 12 | "strict": true, 13 | "stripInternal": true, 14 | "target": "es2020" 15 | }, 16 | "include": ["./src/index.ts"] 17 | } 18 | --------------------------------------------------------------------------------