diff --git a/docs/.eslintrc.cjs b/docs/.eslintrc.cjs new file mode 100644 index 0000000..d6c9537 --- /dev/null +++ b/docs/.eslintrc.cjs @@ -0,0 +1,18 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parser: '@typescript-eslint/parser', + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, +} diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..b398d22 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,26 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +package-lock.json diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..02c0998 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,12 @@ + + + + + + Tailwind Labs Job Application, Adam Plesník + + +
+ + + diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 0000000..71e8ad5 --- /dev/null +++ b/docs/package.json @@ -0,0 +1,51 @@ +{ + "name": "tailwindcss-scroll-driven-animations-docu", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "prettier": { + "printWidth": 100, + "semi": false, + "singleQuote": true, + "trailingComma": "es5", + "plugins": [ + "prettier-plugin-tailwindcss" + ] + }, + "dependencies": { + "@vercel/analytics": "^1.2.2", + "framer-motion": "^11.0.24", + "lucide-react": "^0.363.0", + "prismjs": "^1.29.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.22.3" + }, + "devDependencies": { + "@adam.plesnik/tailwindcss-scroll-driven-animations": "^0.2.5", + "@types/node": "^20.11.30", + "@types/prismjs": "^1.26.3", + "@types/react": "^18.2.66", + "@types/react-dom": "^18.2.22", + "@typescript-eslint/eslint-plugin": "^7.2.0", + "@typescript-eslint/parser": "^7.2.0", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.19", + "eslint": "^8.57.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.6", + "postcss": "^8.4.38", + "postcss-import": "^16.1.0", + "prettier": "^3.2.5", + "prettier-plugin-tailwindcss": "^0.5.12", + "tailwindcss": "^3.4.1", + "typescript": "^5.2.2", + "vite": "^5.2.0" + } +} diff --git a/docs/postcss.config.js b/docs/postcss.config.js new file mode 100644 index 0000000..a8c52ee --- /dev/null +++ b/docs/postcss.config.js @@ -0,0 +1,7 @@ +export default { + plugins: { + 'postcss-import': {}, + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/docs/src/components/ActionButton.tsx b/docs/src/components/ActionButton.tsx new file mode 100644 index 0000000..96c679e --- /dev/null +++ b/docs/src/components/ActionButton.tsx @@ -0,0 +1,57 @@ +import { LucideIcon } from 'lucide-react' +import { MouseEvent, MouseEventHandler, useState } from 'react' + +const ActionButton = ({ + clickAction, + Icon, + IconOnClick = undefined, + tooltip = '', +}: ReplayButtonProps) => { + const [clicked, setClicked] = useState(false) + function handleClick(e: MouseEvent) { + clickAction(e) + setClicked(true) + setTimeout(() => setClicked(false), 2000) + } + return ( +
{ + handleClick(e) + }} + title={tooltip} + className="group cursor-pointer p-1" + > +
+ + {IconOnClick && ( + + )} +
+
+ ) +} + +export interface ReplayButtonProps { + clickAction: MouseEventHandler + Icon: LucideIcon + IconOnClick?: LucideIcon | undefined + tooltip: string +} + +export default ActionButton diff --git a/docs/src/components/Code.tsx b/docs/src/components/Code.tsx new file mode 100644 index 0000000..94260b3 --- /dev/null +++ b/docs/src/components/Code.tsx @@ -0,0 +1,17 @@ +import { PropsWithChildren } from 'react' + +const Code = ({ children }: PropsWithChildren) => ( + + {children} + +) + +export interface InlineCodeProps { + children: PropsWithChildren +} + +export default Code diff --git a/docs/src/components/CodeBlock.tsx b/docs/src/components/CodeBlock.tsx new file mode 100644 index 0000000..853c4de --- /dev/null +++ b/docs/src/components/CodeBlock.tsx @@ -0,0 +1,52 @@ +import { LucideIcon } from 'lucide-react' +import Prism from 'prismjs' +import { PropsWithChildren, useEffect } from 'react' +import Link from './Link.tsx' + +const CodeBlock = ({ + children, + Icon = undefined, + language = 'javascript', + linkHref = '', + linkText = '', +}: PropsWithChildren) => { + useEffect(() => { + Prism.highlightAll() + }, []) + + return ( +
+ + {children} + + {linkHref && ( +
+ {Icon && } + + {linkText ? linkText : linkHref} + +
+ )} +
+ ) +} + +export interface CodeProps { + children: PropsWithChildren + Icon?: LucideIcon | undefined + language?: 'javascript' | 'css' | 'html' + linkHref?: string | undefined + linkText?: string | undefined +} + +export default CodeBlock diff --git a/docs/src/components/DarkModeSwitch.tsx b/docs/src/components/DarkModeSwitch.tsx new file mode 100644 index 0000000..2c9161b --- /dev/null +++ b/docs/src/components/DarkModeSwitch.tsx @@ -0,0 +1,40 @@ +import { motion } from 'framer-motion' +import { Moon, Sun } from 'lucide-react' +import { MouseEventHandler, useState } from 'react' + +const DarkModeSwitch = () => { + const systemDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches + const storageModeDark = localStorage.getItem('mode') === 'dark' + const [darkMode, setDarkMode] = useState(storageModeDark || systemDarkMode ? true : false) + const classList = document.documentElement.classList + darkMode ? classList.add('dark') : classList.remove('dark') + + const switchMode = () => { + localStorage.setItem('mode', darkMode ? 'light' : 'dark') + setDarkMode(!darkMode) + } + + return ( +
+ + + + +
+ ) +} + +export interface ModeSwitchProps { + mode: string + onClick: MouseEventHandler +} + +export default DarkModeSwitch diff --git a/docs/src/components/DemoAnimationExampleRow.tsx b/docs/src/components/DemoAnimationExampleRow.tsx new file mode 100644 index 0000000..91d3a6c --- /dev/null +++ b/docs/src/components/DemoAnimationExampleRow.tsx @@ -0,0 +1,34 @@ +import { addWithSpace } from '../utils/addWithSpace' +import Code from './Code' + +const DemoAnimationExampleRow = ({ animations, timeline }: DemoAnimationExampleRowProps) => { + const animationClasses = animations.split(' ') + return ( +
+
+ {animationClasses.map((animation) => ( + {animation} + ))} +
+
+ 0% + + 100% +
+
+
+ ) +} + +export interface DemoAnimationExampleRowProps { + animations: string + timeline: string +} + +export default DemoAnimationExampleRow diff --git a/docs/src/components/HeaderNavAnchor.tsx b/docs/src/components/HeaderNavAnchor.tsx new file mode 100644 index 0000000..6ba222d --- /dev/null +++ b/docs/src/components/HeaderNavAnchor.tsx @@ -0,0 +1,32 @@ +import { PropsWithChildren } from 'react' +import { NavLink } from 'react-router-dom' + +const HeaderNavAnchor = ({ + children, + to: href, + className = '', + external = false, +}: PropsWithChildren) => { + return ( + + 'flex items-center justify-center rounded-full px-2 text-sm font-medium transition-colors duration-200 md:min-w-12 ' + + 'hover:bg-fuchsia-400/40 dark:hover:bg-slate-500/60 ' + + (isActive ? 'bg-fuchsia-400/30 dark:bg-slate-500/50 ' : '') + + (className != '' ? ` ${className}` : '') + } + target={external ? '_blank' : ''} + > + {children} + + ) +} + +export interface HeaderNavAnchorProps { + children: PropsWithChildren + to: string + className?: string | undefined + external?: boolean +} +export default HeaderNavAnchor diff --git a/docs/src/components/Heading.tsx b/docs/src/components/Heading.tsx new file mode 100644 index 0000000..b16d369 --- /dev/null +++ b/docs/src/components/Heading.tsx @@ -0,0 +1,61 @@ +import { ArrowRight } from 'lucide-react' +import { PropsWithChildren } from 'react' +import { NavLink } from 'react-router-dom' +import { addWithSpace } from '../utils/addWithSpace' + +const Heading = ({ + size = 1, + className = '', + children, + id = '', + href = '', + hrefType = 'documentation', +}: PropsWithChildren) => { + const defaultClasses = + 'relative w-full text-zinc-900 dark:text-zinc-300' + addWithSpace(className) + const anchor = id ? : '' + const link = href ? ( + + + {hrefType === 'documentation' ? 'Documentation' : 'Demo'} + + + + ) : ( + '' + ) + if (size === 1) { + return ( +

+ {children} + {anchor} +

+ ) + } else if (size === 2) { + return ( +

+ {children} + {anchor} +

+ ) + } else { + return ( +

+ {children} + {anchor} + {link} +

+ ) + } +} + +export interface TitleProps { + children: PropsWithChildren + size: 1 | 2 | 3 + className?: string + href?: string + hrefType?: 'documentation' | 'demo' + id?: string +} + +export default Heading diff --git a/docs/src/components/Link.tsx b/docs/src/components/Link.tsx new file mode 100644 index 0000000..5771594 --- /dev/null +++ b/docs/src/components/Link.tsx @@ -0,0 +1,67 @@ +import { PropsWithChildren } from 'react' +import { LucideIcon } from 'lucide-react' + +const Link = ({ + borderWidth = undefined, + children, + className, + href, + Icon = undefined, + iconSize = 16, + iconStrokeWidth = 1.65, + inline = false, + target, +}: PropsWithChildren) => { + return ( +
+ {children} + {Icon ? ( + + ) : ( + '' + )} + + + ) +} + +export interface LinkProps { + children: PropsWithChildren + href: string + target: string + borderWidth?: undefined | 'narrow' | 'none' | 'huge' + className?: string + Icon?: LucideIcon | undefined + iconSize?: number | 16 + iconStrokeWidth?: number | 2 + inline?: boolean +} + +export default Link diff --git a/docs/src/components/PageBackground.tsx b/docs/src/components/PageBackground.tsx new file mode 100644 index 0000000..ed7c0ee --- /dev/null +++ b/docs/src/components/PageBackground.tsx @@ -0,0 +1,14 @@ +const PageBackground = () => { + return ( +
+ ) +} + +export default PageBackground diff --git a/docs/src/components/Paragraph.tsx b/docs/src/components/Paragraph.tsx new file mode 100644 index 0000000..824afdd --- /dev/null +++ b/docs/src/components/Paragraph.tsx @@ -0,0 +1,31 @@ +import { PropsWithChildren } from 'react' + +const Paragraph = ({ + children, + className = '', + size = 'regular', +}: PropsWithChildren) => { + return ( +

+ {children} +

+ ) +} + +export interface ParagraphProps { + children: PropsWithChildren + className?: string + size?: 'regular' | 'large' | 'small' +} + +export default Paragraph diff --git a/docs/src/components/Separator.tsx b/docs/src/components/Separator.tsx new file mode 100644 index 0000000..3425a8f --- /dev/null +++ b/docs/src/components/Separator.tsx @@ -0,0 +1,3 @@ +const Separator = () =>
+ +export default Separator diff --git a/docs/src/components/Skeleton.tsx b/docs/src/components/Skeleton.tsx new file mode 100644 index 0000000..ff96e97 --- /dev/null +++ b/docs/src/components/Skeleton.tsx @@ -0,0 +1,9 @@ +const Skeleton = ({ width = '200px' }) => { + return ( +
+ ) +} +export default Skeleton diff --git a/docs/src/css/keyframes.css b/docs/src/css/keyframes.css new file mode 100644 index 0000000..77e329f --- /dev/null +++ b/docs/src/css/keyframes.css @@ -0,0 +1,73 @@ +@keyframes bounce-bottom { + 0% { + transform: translateY(0); + } + + 40% { + transform: translateY(-3px); + } + + 55% { + transform: translateY(0); + } + + 65% { + transform: translateY(-2px); + } + + 82% { + transform: translateY(0); + } + + 100% { + transform: translateY(0); + } +} + +@keyframes appear { + 0% { + opacity: 0; + transform: scale(0.5); + } + + 40% { + opacity: 1; + transform: scale(1.1); + } + + 60%, + 100% { + opacity: 1; + transform: scale(1); + } +} + +@keyframes scale-to-right { + to { + width: 100%; + } +} + +@keyframes translate-down { + to { + transform: translateY(0); + } +} + +@keyframes translate-up { + from { + transform: translateY(0); + } + to { + transform: translateY(-60%); + } +} + +@keyframes opacity { + from { + opacity: 0; + } + to { + opacity: 1; + } +} diff --git a/docs/src/css/prism.css b/docs/src/css/prism.css new file mode 100644 index 0000000..922ca62 --- /dev/null +++ b/docs/src/css/prism.css @@ -0,0 +1,22 @@ +.function, +.tag, +.atrule { + @apply text-fuchsia-700 dark:text-fuchsia-300; +} + +.literal-property, +.constant, +.string-property, +.attr-value, +.property { + @apply text-sky-700 dark:text-sky-300; +} + +.operator, +.comment { + @apply opacity-60; +} + +.punctuation { + @apply opacity-40; +} diff --git a/docs/src/demos/AppearDemo.tsx b/docs/src/demos/AppearDemo.tsx new file mode 100644 index 0000000..74215e2 --- /dev/null +++ b/docs/src/demos/AppearDemo.tsx @@ -0,0 +1,43 @@ +import Code from '../components/Code' +import Skeleton from '../components/Skeleton' +import DemoTriggerLine from './DemoTriggerLine' +import DemoWrapper from './DemoWrapper' + +const skeletonCollection = [ + '96%', + '100%', + '92%', + '100%', + '93%', + '87%', + '55%', + '100%', + '93%', + '87%', + '55%', +] + +const AppearDemo = () => { + return ( + + +
+ {skeletonCollection.map((width, key) => ( + + ))} +
+ +
+ animate-appear timeline-view +
+ +
+ {skeletonCollection.map((width, key) => ( + + ))} +
+
+ ) +} + +export default AppearDemo diff --git a/docs/src/demos/DemoPlaceholderContent.tsx b/docs/src/demos/DemoPlaceholderContent.tsx new file mode 100644 index 0000000..1bd9959 --- /dev/null +++ b/docs/src/demos/DemoPlaceholderContent.tsx @@ -0,0 +1,25 @@ +import { PropsWithChildren } from 'react' +import { addWithSpace } from '../utils/addWithSpace' + +const DemoPlaceholderContent = ({ + children, + className = '', +}: PropsWithChildren) => { + return ( +
+ {children} +
+ ) +} + +export interface DemoWrapperProps { + children: PropsWithChildren + className?: string +} + +export default DemoPlaceholderContent diff --git a/docs/src/demos/DemoTriggerLine.tsx b/docs/src/demos/DemoTriggerLine.tsx new file mode 100644 index 0000000..9c66851 --- /dev/null +++ b/docs/src/demos/DemoTriggerLine.tsx @@ -0,0 +1,35 @@ +import { addWithSpace } from '../utils/addWithSpace' + +const DemoTriggerLine = ({ + className = '', + explanation = '', + percentage = undefined, +}: DemoTriggerLineProps) => { + return ( +
+
+ {percentage || explanation ? ( +
+ {percentage || percentage === 0 ? ( + {percentage}% + ) : ( + '' + )} + {explanation ? {explanation} : ''} +
+ ) : ( + '' + )} +
+
+ ) +} + +export interface DemoTriggerLineProps { + percentage?: number | undefined + className?: string + explanation?: string + iconClassName?: string +} + +export default DemoTriggerLine diff --git a/docs/src/demos/DemoWrapper.tsx b/docs/src/demos/DemoWrapper.tsx new file mode 100644 index 0000000..1881afe --- /dev/null +++ b/docs/src/demos/DemoWrapper.tsx @@ -0,0 +1,51 @@ +import { PropsWithChildren } from 'react' +import { addWithSpace } from '../utils/addWithSpace' +import ActionButton from '../components/ActionButton' +import { Repeat, StepForward } from 'lucide-react' + +const replayButtonClick = (element: string) => { + const wrapper = document.getElementById(element) + wrapper && + wrapper.getAnimations({ subtree: true }).forEach((anim) => { + anim.cancel() + anim.play() + }) +} + +const DemoWrapper = ({ + children, + className = '', + actionButton = false, + actionButtonElement = 'element', +}: PropsWithChildren) => { + return ( +
+ {actionButton && ( +
+ replayButtonClick(actionButtonElement)} + tooltip="Replay" + /> +
+ )} + {children} +
+ ) +} + +export interface DemoWrapperProps { + children: PropsWithChildren + className?: string + actionButton?: boolean + actionButtonElement?: string +} + +export default DemoWrapper diff --git a/docs/src/demos/MultiRangeDemo.tsx b/docs/src/demos/MultiRangeDemo.tsx new file mode 100644 index 0000000..aa43409 --- /dev/null +++ b/docs/src/demos/MultiRangeDemo.tsx @@ -0,0 +1,51 @@ +import Code from '../components/Code' +import DemoAnimationExampleRow from '../components/DemoAnimationExampleRow' +import Skeleton from '../components/Skeleton' +import DemoWrapper from './DemoWrapper' +import DemoTriggerLine from './DemoTriggerLine' + +const skeletonCollection = [ + '46%', + '100%', + '92%', + '100%', + '93%', + '87%', + '55%', + '100%', + '93%', + '87%', + '55%', +] + +const MultiRangeDemo = () => { + return ( + +
+ + + + +
+
+
+ {skeletonCollection.map((width, key) => ( + + ))} +
+ +
+ view-timeline/demo +
+ +
+ {skeletonCollection.map((width, key) => ( + + ))} +
+
+
+ ) +} + +export default MultiRangeDemo diff --git a/docs/src/demos/ProgressBarDemo.tsx b/docs/src/demos/ProgressBarDemo.tsx new file mode 100644 index 0000000..251ab82 --- /dev/null +++ b/docs/src/demos/ProgressBarDemo.tsx @@ -0,0 +1,41 @@ +import Code from '../components/Code' +import Skeleton from '../components/Skeleton' +import DemoTriggerLine from './DemoTriggerLine' +import DemoWrapper from './DemoWrapper' + +const skeletonCollection = ['96%', '100%', '92%', '100%', '93%', '87%', '55%'] + +const ProgressBarDemo = () => { + return ( + +
+ animate-scale-to-right timeline +
+
+ + +
+ {skeletonCollection.map((width, key) => ( + + ))} +
+ {skeletonCollection.map((width, key) => ( + + ))} + +
+
+ ) +} + +export default ProgressBarDemo diff --git a/docs/src/demos/RangeDemo.tsx b/docs/src/demos/RangeDemo.tsx new file mode 100644 index 0000000..97d9bd1 --- /dev/null +++ b/docs/src/demos/RangeDemo.tsx @@ -0,0 +1,52 @@ +import Code from '../components/Code' +import Skeleton from '../components/Skeleton' +import DemoPlaceholderContent from './DemoPlaceholderContent' +import DemoTriggerLine from './DemoTriggerLine' +import DemoWrapper from './DemoWrapper' + +const skeletonCollection = ['96%', '100%', '92%', '100%', '93%', '87%', '55%'] + +const RangeDemo = () => { + return ( + +
+
+ animate... + timeline/navbar + range-on-exit +
+
+ scope/navbar +
+ + + + + +
+ view-timeline/navbar +
+ +
+ {skeletonCollection.map((width, key) => ( + + ))} +
+ {skeletonCollection.map((width, key) => ( + + ))} +
+
+
+ ) +} + +export default RangeDemo diff --git a/docs/src/demos/SupportsDemo.tsx b/docs/src/demos/SupportsDemo.tsx new file mode 100644 index 0000000..5eb9198 --- /dev/null +++ b/docs/src/demos/SupportsDemo.tsx @@ -0,0 +1,41 @@ +import Code from '../components/Code' +import Skeleton from '../components/Skeleton' +import DemoWrapper from './DemoWrapper' + +const skeletonCollection = [ + '96%', + '100%', + '92%', + '100%', + '93%', + '75%', + '32%', + '96%', + '100%', + '92%', + '100%', + '93%', + '75%', + '32%', +] + +const SupportsDemo = () => { + return ( + +
+ animate... + timeline + -translate-y-20 + no-animations:translate-y-0 +
+
+ + {skeletonCollection.map((width, key) => ( + + ))} +
+
+ ) +} + +export default SupportsDemo diff --git a/docs/src/demos/TimelineOverrideDemo.tsx b/docs/src/demos/TimelineOverrideDemo.tsx new file mode 100644 index 0000000..f65b88c --- /dev/null +++ b/docs/src/demos/TimelineOverrideDemo.tsx @@ -0,0 +1,38 @@ +import Code from '../components/Code' +import Skeleton from '../components/Skeleton' +import DemoWrapper from './DemoWrapper' + +const skeletonCollection = ['46%', '100%', '92%', '100%', '93%', '87%', '93%', '87%', '55%'] + +const TimelineOverrideDemo = () => { + return ( + +
+
+ + timeline before{' '} + animate-opacity + +
+
+ + animate-opacity before{' '} + timeline + +
+
+
+ {skeletonCollection.map((width, key) => ( + + ))} +
+
+ {skeletonCollection.map((width, key) => ( + + ))} +
+
+ ) +} + +export default TimelineOverrideDemo diff --git a/docs/src/docs/Docs.tsx b/docs/src/docs/Docs.tsx new file mode 100644 index 0000000..5fe527f --- /dev/null +++ b/docs/src/docs/Docs.tsx @@ -0,0 +1,140 @@ +import Code from '../components/Code' +import CodeBlock from '../components/CodeBlock' +import Heading from '../components/Heading' +import Paragraph from '../components/Paragraph' +import MultiRangeDemo from '../demos/MultiRangeDemo' +import SupportsDemo from '../demos/SupportsDemo' +import TimelineOverrideDemo from '../demos/TimelineOverrideDemo' +import { + keyframes101, + keyframes102, + keyframes103, + multiRange, + multiRangeKeyframes, + supports, +} from '../utils/demoExamples' +import DocsTable from './DocsTable' + +const animationTimelineClasses = [ + { className: 'timeline', code: 'animation-timeline: scroll(y)' }, + { className: 'timeline-scroll-x', code: 'animation-timeline: scroll(x)' }, + { className: 'timeline-view', code: 'animation-timeline: view()' }, + { className: 'timeline-auto', code: 'animation-timeline: auto' }, + { className: 'timeline-none', code: 'animation-timeline: none' }, + { className: 'timeline/{name}', code: 'animation-timeline: --{name}' }, +] + +const scopeClasses = [{ className: 'scope/{name}', code: 'timeline-scope: --{name}' }] + +const rangeClasses = [ + { className: 'range', code: 'animation-range: cover 0% cover 100%' }, + { className: 'range-contain', code: 'animation-range: contain 0% contain 100%' }, + { className: 'range-on-entry', code: 'animation-range: entry 0% entry 100%' }, + { className: 'range-on-exit', code: 'animation-range: exit 0% exit 100%' }, + { + className: 'range/{startValue},{endValue}', + code: 'animation-range: cover {value} cover {endValue}', + }, +] + +const scrollTimelineClasses = [ + { className: 'scroll-timeline/{name}', code: 'scroll-timeline: --{name} y' }, + { className: 'scroll-timeline-x/{name}', code: 'scroll-timeline: --{name} x' }, + { className: 'scroll-timeline-block/{name}', code: 'scroll-timeline: --{name} block' }, +] + +const viewTimelineClasses = [ + { className: 'view-timeline/{name}', code: 'view-timeline: --{name} y' }, + { className: 'view-timeline-x/{name}', code: 'view-timeline: --{name} x' }, + { className: 'view-timeline-block/{name}', code: 'view-timeline: --{name} block' }, +] + +const supportsClasses = [ + { className: 'no-animations:...', code: '@supports not (animation-range: cover) { ... }' }, +] + +const Docs = () => { + return ( +
+ + Documentation + + + How to Make Your CSS Animation Scroll-driven + + + CSS animations consist of two components, a set of keyframes and a style describing the + animation. Let's declare a simple @opacity keyframe set and apply it to an + element we want to control by a scroll timeline. + + {keyframes101} + {keyframes102} + + To effectively control the animation, make sure to declare the timeline in the code after + the animation. By default, the shorthand animation property sets the{' '} + animation-timeline: auto unless set otherwise. However, using this plugin and + Tailwind CSS animations ensures that the declaration order is correct. + + {keyframes103} + Scroll the container. + + + Animation Timeline + + + Utility class specifying the timeline that is used to control the progress of a CSS + animation. + + + + Scroll Timeline + + + Utility class setting the named scroll progress timeline, which is set on a scrollable + element. + + + + View Timeline + + + Utility class setting the named view progress timeline, which is set on a subject inside + another scrollable element. + + + + Animation Range + + + Animation range start controls where along the timeline an animation will start. It is set + on the animated element. + + + + Scroll the container to see each how range utility class affects the animation. + + + {multiRange} + {multiRangeKeyframes} + + Timeline Scope + + + Timeline scope allows to control animations outside the element which defines the timeline. + + + + Fallback Styling + + + Use the no-animations modifier to apply fallback styling in browsers which do + not support scroll-driven animations yet. + + + + {supports} +
+ ) +} + +export default Docs diff --git a/docs/src/docs/DocsTable.tsx b/docs/src/docs/DocsTable.tsx new file mode 100644 index 0000000..3a7d8f1 --- /dev/null +++ b/docs/src/docs/DocsTable.tsx @@ -0,0 +1,32 @@ +import Separator from '../components/Separator' +import DocsTableRow from './DocsTableRow' + +const DocsTable = ({ items }: DocsTableProps) => { + return ( +
+
+
Class
+
/
+
Code
+
+ + {items.map((item, index) => ( + <> + + + + ))} +
+ ) +} + +export interface DocsTableProps { + items: Item[] +} + +export interface Item { + className: string + code: string +} + +export default DocsTable diff --git a/docs/src/docs/DocsTableRow.tsx b/docs/src/docs/DocsTableRow.tsx new file mode 100644 index 0000000..4075490 --- /dev/null +++ b/docs/src/docs/DocsTableRow.tsx @@ -0,0 +1,15 @@ +const DocsTableRow = ({ className, code }: DocsTableRowProps) => { + return ( +
+
{className}
+
{code};
+
+ ) +} + +export interface DocsTableRowProps { + className: string + code: string +} + +export default DocsTableRow diff --git a/docs/src/fonts/inter-variable.woff2 b/docs/src/fonts/inter-variable.woff2 new file mode 100644 index 0000000..1c91452 Binary files /dev/null and b/docs/src/fonts/inter-variable.woff2 differ diff --git a/docs/src/fonts/inter.css b/docs/src/fonts/inter.css new file mode 100644 index 0000000..4568d0c --- /dev/null +++ b/docs/src/fonts/inter.css @@ -0,0 +1,7 @@ +@font-face { + font-display: block; + font-family: 'Inter'; + font-style: normal; + font-weight: 100 900; + src: url('./inter-variable.woff2') format('woff2'); +} diff --git a/docs/src/index.css b/docs/src/index.css new file mode 100644 index 0000000..57729c4 --- /dev/null +++ b/docs/src/index.css @@ -0,0 +1,7 @@ +@import 'fonts/inter.css'; +@import 'css/keyframes.css'; +@import 'css/prism.css'; + +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/docs/src/layouts/Page.tsx b/docs/src/layouts/Page.tsx new file mode 100644 index 0000000..e30cb74 --- /dev/null +++ b/docs/src/layouts/Page.tsx @@ -0,0 +1,27 @@ +import { PropsWithChildren } from 'react' +import { ScrollRestoration } from 'react-router-dom' +import PageBackground from '../components/PageBackground' +import Nav from '../partials/Nav' +import Footer from '../partials/Footer' +import { Analytics } from '@vercel/analytics/react' + +function Page({ children }: PropsWithChildren) { + return ( +
+ +
+ ) +} + +export interface PageProps { + children: PropsWithChildren +} + +export default Page diff --git a/docs/src/main.tsx b/docs/src/main.tsx new file mode 100644 index 0000000..cf445a8 --- /dev/null +++ b/docs/src/main.tsx @@ -0,0 +1,28 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { RouterProvider, createBrowserRouter } from 'react-router-dom' +import './index.css' +import HomeView from './views/HomeView' +import DocsView from './views/DocsView' + +const router = createBrowserRouter([ + { + path: '/', + element: , + errorElement: , + }, + { + path: '/usage', + element: , + }, + { + path: '/docs', + element: , + }, +]) + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +) diff --git a/docs/src/partials/Animations.tsx b/docs/src/partials/Animations.tsx new file mode 100644 index 0000000..f4e7070 --- /dev/null +++ b/docs/src/partials/Animations.tsx @@ -0,0 +1,131 @@ +import { Github, Minus } from 'lucide-react' +import Code from '../components/Code.tsx' +import CodeBlock from '../components/CodeBlock.tsx' +import Heading from '../components/Heading.tsx' +import Paragraph from '../components/Paragraph.tsx' +import { + codeExampleRange, + codeExampleScope, + codeExampleSupports, + codeExampleTimeline, + codeExampleView, +} from '../utils/codeExamples.ts' + +const Animations = () => { + return ( + <> + + Plugin + + + The plugin provides utilities for a subset of CSS scroll-driven animation properties: + +
    +
  • + + animation-timeline +
  • +
  • + + scroll-timeline, view-timeline +
  • +
  • + + animation-range +
  • +
  • + + timeline-scope +
  • +
+ + + Animation Timeline + + + 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. Utility + below 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. + + + {codeExampleTimeline} + + + Scroll and View Timeline + + + 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. + + + {codeExampleView} + + + Range + + + Animation range controls start and end of an animation. Utility class range{' '} + offers multiple options with default value set to cover. + + + {codeExampleRange} + + + Scope + + + Timeline scope allows to control animations outside the element which defines the timeline. + Utility scope should be used with a modifier to define the timeline name set by{' '} + scroll-timeline or view-timeline. + + + {codeExampleScope} + + + Fallback Styling + + + Scroll-driven animations are not broadly supported yet. I decided to apply an + animation-first approach. Use the no-animations modifier for fallback styling. + + + {codeExampleSupports} + + + ) +} + +export default Animations diff --git a/docs/src/partials/Demo.tsx b/docs/src/partials/Demo.tsx new file mode 100644 index 0000000..3ea6411 --- /dev/null +++ b/docs/src/partials/Demo.tsx @@ -0,0 +1,59 @@ +import Code from '../components/Code' +import CodeBlock from '../components/CodeBlock' +import Heading from '../components/Heading' +import Paragraph from '../components/Paragraph' +import AppearDemo from '../demos/AppearDemo' +import ProgressBarDemo from '../demos/ProgressBarDemo' +import RangeDemo from '../demos/RangeDemo' +import { + appearDemo, + appearKeyframes, + progressBarDemo, + progressBarKeyframes, + rangeDemo, + rangeKeyframes, +} from '../utils/demoExamples' + +const Demo = () => { + return ( + <> + Demos + + Anonymous Scroll Timeline + + + This demo showcases how to create a simple progress bar just by adding one utility class to + the element. We define the anonymous scroll timeline by adding timeline to the + progress bar. + + + {progressBarDemo} + {progressBarKeyframes} + + Anonymous View Timeline + + + This demo showcases how to make the element appear after entering the view frame. We define + the anonymous view timeline by adding timeline-view to this element. + + + {appearDemo} + {appearKeyframes} + + Range, Scope and Animation Timeline Name + + + This demo showcases the usage of the plugin to reveal the navigation bar. The{' '} + view-timeline/navbar utility sets up the animation timeline, which is then + scoped out of the defining element by scope/navbar. The navigation bar is + controlled by this timeline with the timeline/navbar utility. Utility class{' '} + range-on-exit is set to limit the timeline duration. + + + {rangeDemo} + {rangeKeyframes} + + ) +} + +export default Demo diff --git a/docs/src/partials/Footer.tsx b/docs/src/partials/Footer.tsx new file mode 100644 index 0000000..c56ece4 --- /dev/null +++ b/docs/src/partials/Footer.tsx @@ -0,0 +1,23 @@ +import Link from '../components/Link' + +const Footer = () => { + return ( +
+ Created by Adam Plesník, Bratislava, Slovakia +
+ + github.com/tailwindcss-scroll-driven-animations + + + adamplesnik.com + +
+
+ ) +} + +export default Footer diff --git a/docs/src/partials/Intro.tsx b/docs/src/partials/Intro.tsx new file mode 100644 index 0000000..2889c91 --- /dev/null +++ b/docs/src/partials/Intro.tsx @@ -0,0 +1,25 @@ +import { CornerRightDown } from 'lucide-react' +import Link from '../components/Link' +import Paragraph from '../components/Paragraph' + +const Intro = () => { + return ( + <> + + I remember being yelled at by a senior Java developer when I proudly integrated some atomic + classes into our dinosaur project. It was back in 2018, I didn’t back out and our collection + of Tailwind CSS classes has been growing every day. + + + One of many stand-out features of Tailwind CSS is how it guides developers to utilize the + edge CSS features simply by exploring its documentation.{' '} + + Scroll-driven animations + {' '} + you plan to introduce are no exception to this. + + + ) +} + +export default Intro diff --git a/docs/src/partials/MainTitle.tsx b/docs/src/partials/MainTitle.tsx new file mode 100644 index 0000000..fd7e7ee --- /dev/null +++ b/docs/src/partials/MainTitle.tsx @@ -0,0 +1,19 @@ +import Heading from '../components/Heading.tsx' +import Link from '../components/Link.tsx' +import Paragraph from '../components/Paragraph.tsx' + +const MainTitle = () => { + return ( + <> + Scroll-driven Animations for Tailwind CSS + + Bratislava, Slovakia,{' '} + + adamplesnik.com + + + + ) +} + +export default MainTitle diff --git a/docs/src/partials/Me.tsx b/docs/src/partials/Me.tsx new file mode 100644 index 0000000..4ef6bb7 --- /dev/null +++ b/docs/src/partials/Me.tsx @@ -0,0 +1,32 @@ +import { TrendingUp } from 'lucide-react' +import Heading from '../components/Heading.tsx' +import Link from '../components/Link.tsx' +import Paragraph from '../components/Paragraph.tsx' + +const Me = () => { + return ( + <> + + Me + + + I am married, 38 years old, father of two kids, living in Bratislava, Slovakia. While my + passion for coding is obvious, I also enjoy mountain biking, traveling, and spending quality + time with my family. + + + I speak English and French fluently, and because I love Portugal, I'm also learning + Portuguese. + + + Learn more about me at my{' '} + + personal page + + . + + + ) +} + +export default Me diff --git a/docs/src/partials/Nav.tsx b/docs/src/partials/Nav.tsx new file mode 100644 index 0000000..b0eb7e9 --- /dev/null +++ b/docs/src/partials/Nav.tsx @@ -0,0 +1,25 @@ +import { Github } from 'lucide-react' +import DarkModeSwitch from '../components/DarkModeSwitch.tsx' +import HeaderNavAnchor from '../components/HeaderNavAnchor.tsx' + +const Nav = () => { + return ( +
+
+ Plugin + Docs +
+ + + + +
+
+ ) +} + +export default Nav diff --git a/docs/src/utils/addWithSpace.ts b/docs/src/utils/addWithSpace.ts new file mode 100644 index 0000000..59917a8 --- /dev/null +++ b/docs/src/utils/addWithSpace.ts @@ -0,0 +1,3 @@ +export const addWithSpace = (value: string) => { + return value ? ` ${value}` : '' +} diff --git a/docs/src/utils/codeExamples.ts b/docs/src/utils/codeExamples.ts new file mode 100644 index 0000000..663a4da --- /dev/null +++ b/docs/src/utils/codeExamples.ts @@ -0,0 +1,44 @@ +export const codeExampleTimeline = `matchUtilities( + { + timeline: (value, { modifier }) => ({ + 'animation-timeline': modifier ? \`--\${modifier}\` : value, + }), + }, + { + values: { + DEFAULT: 'scroll(y)', + auto: 'auto', + none: 'none', + 'scroll-x': 'scroll(x)', + view: 'view()', + }, + modifiers: 'any', + } +)` + +export const codeExampleView = `'view-timeline': (value, { modifier }) => ({ + viewTimeline: (modifier ? \`--\${modifier} \` : 'none ') + value, +}),` + +export const codeExampleRange = `matchUtilities( + { + range: (value, { modifier }) => ({ + animationRange: splitAndCombine(value, modifier), + }), + }, + { + values: { + DEFAULT: 'cover cover', + 'on-entry': 'entry entry', + 'on-exit': 'exit exit', + contain: 'contain contain', + }, + modifiers: 'any', + } +)` + +export const codeExampleScope = `scope: (value, { modifier }) => ({ + timelineScope: \`--\${modifier}\`, +}),` + +export const codeExampleSupports = `addVariant('no-animations', '@supports not (animation-range: cover)')` diff --git a/docs/src/utils/demoExamples.ts b/docs/src/utils/demoExamples.ts new file mode 100644 index 0000000..07b09c7 --- /dev/null +++ b/docs/src/utils/demoExamples.ts @@ -0,0 +1,110 @@ +export const progressBarDemo = `
+
+ +
+
+ ... +
+
` + +export const progressBarKeyframes = `@keyframes scale-to-right { + to { + width: 100%; + } +}` + +export const rangeDemo = `
+ +
+ +
+
+ ... +
+ +
+ ... +
+
` + +export const rangeKeyframes = `@keyframes translate-down { + to { + transform: translateY(0); + } +}` + +export const appearDemo = `
+ ... +
+ +
+ ... +
` + +export const appearKeyframes = `@keyframes appear { + 0% { + opacity: 0; + transform: scale(0.5); + } + + 40% { + opacity: 1; + transform: scale(1.1); + } + + 60%, + 100% { + opacity: 1; + transform: scale(1); + } +}` + +export const multiRange = `
+ +
+ +
+ ... +
+ +
+
` + +export const multiRangeKeyframes = `@keyframes scale-to-right { + to { + width: 100%; + } +}` + +export const supports = `
+
+ +
+ ... +
` + +export const keyframes101 = `@keyframes opacity { + from { + opacity: 0; + } + to { + opacity: 1; + } +}` + +export const keyframes102 = `
+ +
` + +export const keyframes103 = ` +.timeline { /* This timeline would be overriden */ + animation-timeline: scroll(y); +} + +.animate-opacity { + animation: opacity 8s ease-in-out both; +} + +.timeline { /* This is the correct order */ + animation-timeline: scroll(y); +}` diff --git a/docs/src/views/DocsView.tsx b/docs/src/views/DocsView.tsx new file mode 100644 index 0000000..5d25796 --- /dev/null +++ b/docs/src/views/DocsView.tsx @@ -0,0 +1,23 @@ +import { WandSparkles } from 'lucide-react' +import Docs from '../docs/Docs' +import Page from '../layouts/Page' +import Demo from '../partials/Demo' + +const DocsView = () => { + return ( + +
+ + Scroll-driven animations are not yet supported by your browser. Use Chrome 116 or newer to + see the demos working correctly. +
+ + +
+ ) +} + +export default DocsView diff --git a/docs/src/views/HomeView.tsx b/docs/src/views/HomeView.tsx new file mode 100644 index 0000000..dad8dfb --- /dev/null +++ b/docs/src/views/HomeView.tsx @@ -0,0 +1,14 @@ +import Page from '../layouts/Page.tsx' +import Animations from '../partials/Animations.tsx' +import MainTitle from '../partials/MainTitle.tsx' + +function HomeView() { + return ( + + + + + ) +} + +export default HomeView diff --git a/docs/src/vite-env.d.ts b/docs/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/docs/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/docs/tailwind.config.js b/docs/tailwind.config.js new file mode 100644 index 0000000..d26af75 --- /dev/null +++ b/docs/tailwind.config.js @@ -0,0 +1,34 @@ +/** @type {import('tailwindcss').Config} */ +const defaultTheme = require('tailwindcss/defaultTheme') + +module.exports = { + darkMode: 'selector', + content: ['./src/**/*.{js,ts,jsx,tsx}', './index.html'], + theme: { + extend: { + fontFamily: { + sans: ['Inter', ...defaultTheme.fontFamily.sans], + }, + animation: { + /* Regular CSS animations */ + 'bounce-bottom': 'bounce-bottom 550ms ease-in-out 220ms', + + /* Scroll-driven animations */ + appear: 'appear auto cubic-bezier(0.65, 0.05, 0.17, 0.99) forwards', + down: 'translate-down auto ease-in-out forwards', + opacity: 'opacity 8s ease-in-out forwards', + 'scale-to-right': 'scale-to-right auto linear forwards', + 'translate-down': 'translate-down auto cubic-bezier(0.65, 0.05, 0.17, 0.99) forwards', + 'translate-up': 'translate-up auto ease-in-out forwards', + }, + transitionTimingFunction: { + bounce: 'cubic-bezier(0.26, 0.53, 1, 0.63)', + line: 'cubic-bezier(0.65, 0.05, 0.17, 0.99)', + }, + }, + supports: { + animations: 'animation-timeline: scroll(y)', + }, + }, + plugins: [require('@adam.plesnik/tailwindcss-scroll-driven-animations')], +} diff --git a/docs/tsconfig.json b/docs/tsconfig.json new file mode 100644 index 0000000..a7fc6fb --- /dev/null +++ b/docs/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/docs/tsconfig.node.json b/docs/tsconfig.node.json new file mode 100644 index 0000000..97ede7e --- /dev/null +++ b/docs/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/docs/vercel.json b/docs/vercel.json new file mode 100644 index 0000000..0f32683 --- /dev/null +++ b/docs/vercel.json @@ -0,0 +1,3 @@ +{ + "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] +} diff --git a/docs/vite.config.ts b/docs/vite.config.ts new file mode 100644 index 0000000..5a33944 --- /dev/null +++ b/docs/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], +}) diff --git a/package.json b/package.json index 90010fd..251d25d 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,12 @@ "version": "0.2.5", "author": "Adam Plesnik ", "scripts": { + "dev-docs": "npm run --workspace=docs dev", "build": "swc ./src/index.ts --out-dir ./dist" }, + "workspaces": [ + "docs" + ], "prettier": { "printWidth": 100, "semi": false,