├── .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 | 
4 | 
5 | 
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 |
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 |
13 |
14 |
{children}
15 |
16 |
17 |
18 |
19 |
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 |
--------------------------------------------------------------------------------