├── .github └── workflows │ └── ci-cd.yaml ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── demo ├── .eslintrc.cjs ├── .gitignore ├── index.html ├── package.json ├── public │ └── doge.png ├── src │ ├── App.tsx │ ├── doge.png │ ├── main.tsx │ └── styles.css ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── index.html ├── jest.config.js ├── package-lock.json ├── package.json ├── react-dvd-screensaver ├── README.md ├── package.json ├── src │ ├── DvdScreensaver.tsx │ ├── index.tsx │ ├── useDvdScreensaver.ts │ └── utils.ts ├── tsconfig.json └── tsup.config.ts ├── setupTests.ts └── tsconfig.json /.github/workflows/ci-cd.yaml: -------------------------------------------------------------------------------- 1 | name: CI/CD 2 | 3 | on: 4 | push: 5 | branches: [ '**' ] 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Set up Node.js 14 | uses: actions/setup-node@v4 15 | with: 16 | node-version: '20.x' 17 | - run: npm ci 18 | - run: npm test 19 | 20 | deploy: 21 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 22 | # needs: test 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | - name: Set up Node.js 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: '20.x' 31 | 32 | - name: Install dependencies 33 | run: npm install 34 | - name: Build package 35 | run: npm run build:package 36 | - name: Build demo 37 | run: npm run build:demo 38 | 39 | - name: Deploy to S3 40 | uses: jakejarvis/s3-sync-action@v0.5.1 41 | with: 42 | args: --acl public-read --follow-symlinks --delete 43 | env: 44 | AWS_S3_BUCKET: ${{ secrets.AWS_BUCKET_NAME }} 45 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY }} 46 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET }} 47 | AWS_REGION: 'eu-central-1' 48 | SOURCE_DIR: 'demo/dist' 49 | DEST_DIR: 'react-dvd-screensaver' 50 | 51 | - name: Invalidate CloudFront cache 52 | uses: chetan/invalidate-cloudfront-action@v1 53 | env: 54 | DISTRIBUTION: ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} 55 | PATHS: '/react-dvd-screensaver/*' 56 | AWS_REGION: 'eu-central-1' 57 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY }} 58 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/node_modules/ 2 | **/.DS_Store 3 | **/to.do 4 | **/.vscode 5 | **/.editorconfig 6 | **/.prettierrc 7 | **/.eslintrc 8 | **/yarn-error.log 9 | **/.env 10 | **/.jest 11 | **/dist 12 | **/.history 13 | **/.cache -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | tsconfig.json 2 | src 3 | to.do 4 | node_modules -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React DVD Screensaver 2 | 3 | [![TypeScript](https://badges.frapsoft.com/typescript/code/typescript.svg?v=101)](https://github.com/ellerbrock/typescript-badges/) 4 | 5 | DVD-era nostalgia in React. 6 | 7 | [Demo](https://samuel.weckstrom.xyz/react-dvd-screensaver) 8 | 9 | [Try the project on Stackblitz](https://stackblitz.com/~/github.com/samuelweckstrom/react-dvd-screensaver) 10 | 11 |
12 | 13 | ``` 14 | npm i react-dvd-screensaver 15 | ``` 16 | 17 |
18 | 19 | ## Use hook 20 | 21 | To add a DVD screensaver effect to your React components, you can use the useDvdScreensaver hook. This hook provides you with references for both the parent (container) and the child (moving element), along with the number of times the child has hit the edges of the container. 22 |
23 | 24 | ```typescript 25 | import { useDvdScreensaver } from 'react-dvd-screensaver' 26 | 27 | ... 28 | 29 | const { containerRef, elementRef } = useDvdScreensaver(); 30 | 31 | return ( 32 |
33 | 34 |
35 | ) 36 | 37 | ``` 38 | 39 | Pass the `ref` objects for parent and child to their respective components. Just remember to set the dimensions for both of them, where the `childRef` component naturally is smaller than the parent so there is room for it to move around. 40 | 41 | | Hook returns following:|| 42 | | ------------- | ------------- | 43 | |`parentRef: refObject`| Ref of the parent container component| 44 | |`elementRef: refObject`| Ref of the element to animate| 45 | |`impactCount: number`| Number of times the element has hit the container edges| 46 | 47 |
48 | 49 | ### Hook Options 50 | 51 | The hook accepts the following parameters: 52 | 53 | | Option | Type | Description | 54 | | ------------- | -------|-----| 55 | |`speed`| `number` | Speed of the animation | 56 | |`freezeOnHover`| `boolean` | Whether to pause the animation on hover | 57 | |`hoverCallback`| `Function` | Callback function triggered on hover | 58 | 59 |
60 | 61 | ## Component 62 | 63 | For easier implementation, you can also use the DvdScreensaver component to wrap any child component with the DVD screensaver effect. 64 | 65 |
66 | 67 | ```typescript 68 | import { DvdScreensaver } from 'react-dvd-screensaver' 69 | 70 | ... 71 | 72 | return ( 73 |
74 | 75 | 76 | 77 |
78 | ) 79 | ``` 80 | 81 | The component inherits the parent container's dimensions by default, but you can also specify dimensions or styling directly via props. 82 | 83 |
84 | 85 | ### Props 86 | 87 | | Prop | Type | Description | 88 | | ------------- | ------| -----| 89 | |`className`| `string` | Optional CSS class for the container | 90 | |`freezeOnHover`| `boolean` | Pause animation when hovered | 91 | |`height`| `number` | Optional height for the container | 92 | |`width`| `number` | Optional width for the container | 93 | |`hoverCallback|`Function` | Callback function triggered on hover | 94 | |`impactCallback`|`(count: number) => void`|Callback function triggered on impact with edges | 95 | |`speed`|`number`| Speed of the animation | 96 | 97 | ## License 98 | 99 | [MIT](LICENSE) 100 | -------------------------------------------------------------------------------- /demo/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { browser: true, es2020: true }, 3 | extends: [ 4 | 'eslint:recommended', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'plugin:react-hooks/recommended', 7 | ], 8 | parser: '@typescript-eslint/parser', 9 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, 10 | plugins: ['react-refresh'], 11 | rules: { 12 | 'react-refresh/only-export-components': 'warn', 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /demo/.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 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | React DVD Screensaver demo 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite --force", 8 | "build": "tsc && vite build", 9 | "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "react": "^18.2.0", 14 | "react-dom": "^18.2.0", 15 | "react-dvd-screensaver": "*" 16 | }, 17 | "devDependencies": { 18 | "@types/react": "^18.0.28", 19 | "@types/react-dom": "^18.0.11", 20 | "@typescript-eslint/eslint-plugin": "^5.57.1", 21 | "@typescript-eslint/parser": "^5.57.1", 22 | "@vitejs/plugin-react": "^4.0.0", 23 | "eslint": "^8.38.0", 24 | "eslint-plugin-react-hooks": "^4.6.0", 25 | "eslint-plugin-react-refresh": "^0.3.4", 26 | "typescript": "^5.0.2", 27 | "vite": "^4.5.3" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /demo/public/doge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samuelweckstrom/react-dvd-screensaver/704d9606fa8d987452a96a0280daa728e66d601b/demo/public/doge.png -------------------------------------------------------------------------------- /demo/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { useDvdScreensaver, DvdScreensaver } from 'react-dvd-screensaver'; 3 | import './styles.css'; 4 | 5 | const COLORS = [ 6 | '#ff0000', 7 | '#ff4000', 8 | '#ff8000', 9 | '#ffbf00', 10 | '#ffff00', 11 | '#bfff00', 12 | '#80ff00', 13 | '#40ff00', 14 | '#00ff00', 15 | '#00ff40', 16 | '#00ff80', 17 | '#00ffbf', 18 | '#00ffff', 19 | '#00bfff', 20 | '#0080ff', 21 | '#0040ff', 22 | '#0000ff', 23 | '#4000ff', 24 | '#8000ff', 25 | '#bf00ff', 26 | '#ff00ff', 27 | '#ff00bf', 28 | '#ff0080', 29 | '#ff0040', 30 | '#ff0000', 31 | ] as const; 32 | 33 | export function App() { 34 | const { containerRef, elementRef, hovered, impactCount } = useDvdScreensaver({ 35 | freezeOnHover: true, 36 | speed: 2, 37 | }); 38 | const [componentImpactCount, setComponentImpactCount] = useState(0); 39 | const [logoColor, setLogoColor] = useState(COLORS[0]); 40 | const handleComponentImpactCount = (count: number) => { 41 | setComponentImpactCount(count); 42 | }; 43 | 44 | useEffect(() => { 45 | if (hovered) { 46 | console.log('* FROZEN'); 47 | } 48 | }, [hovered]); 49 | 50 | useEffect(() => { 51 | setLogoColor(COLORS[Math.floor(Math.random() * COLORS.length)]); 52 | }, [impactCount]); 53 | 54 | return ( 55 |
56 |
57 |

Hooks example

58 |

impact count: {impactCount}

59 |
60 |
61 | 66 | 67 | 68 |
69 |
70 |
71 |
72 |

Component example

73 |

impact count: {componentImpactCount}

74 |
75 | 76 | 77 | 78 |
79 |
80 |
81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /demo/src/doge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samuelweckstrom/react-dvd-screensaver/704d9606fa8d987452a96a0280daa728e66d601b/demo/src/doge.png -------------------------------------------------------------------------------- /demo/src/main.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom/client'; 2 | import { App } from './App'; 3 | 4 | console.log('main.tsx'); 5 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 6 | 7 | ); 8 | -------------------------------------------------------------------------------- /demo/src/styles.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | padding: 0; 5 | font-family: Arial, Helvetica, sans-serif; 6 | box-sizing: border-box; 7 | font-size: 10px; 8 | } 9 | 10 | .contents { 11 | width: 100vw; 12 | height: 100vh; 13 | } 14 | 15 | .hooks-container { 16 | width: 100vw; 17 | height: 40rem; 18 | position: relative; 19 | border: 1px solid; 20 | background-color: black; 21 | } 22 | 23 | .hooks-element { 24 | width: 10rem; 25 | } 26 | 27 | .hooks-element svg { 28 | pointer-events: none; 29 | } 30 | 31 | 32 | @keyframes blink { 33 | 0% { 34 | opacity: 1.0; 35 | } 36 | 37 | 50% { 38 | opacity: 0.0; 39 | } 40 | 41 | 100% { 42 | opacity: 1.0; 43 | } 44 | } 45 | 46 | .component-parent { 47 | width: 100vw; 48 | height: 20rem; 49 | position: relative; 50 | border: 1px solid; 51 | } 52 | 53 | .component-parent img { 54 | width: 5rem; 55 | height: auto; 56 | } -------------------------------------------------------------------------------- /demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": [ 5 | "DOM", 6 | "DOM.Iterable", 7 | "ESNext" 8 | ], 9 | "module": "ESNext", 10 | "skipLibCheck": true, 11 | /* Bundler mode */ 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | /* Linting */ 19 | "strict": false, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true 23 | }, 24 | "include": [ 25 | "src" 26 | ], 27 | "references": [ 28 | { 29 | "path": "./tsconfig.node.json" 30 | } 31 | ] 32 | } -------------------------------------------------------------------------------- /demo/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /demo/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: [react()], 7 | base: './', 8 | server: { 9 | host: true, 10 | }, 11 | build: { 12 | outDir: './dist', 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | React Record Webcam demo 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'jsdom', 3 | preset: 'ts-jest/presets/js-with-ts', 4 | rootDir: __dirname, 5 | setupFilesAfterEnv: ['/setupTests.ts'], 6 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(ts|tsx|js|jsx)?$', 7 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], 8 | testPathIgnorePatterns: [ 9 | '/node_modules', 10 | '.history', 11 | '/react-say-something/dist', 12 | ], 13 | globals: { 14 | 'ts-jest': { 15 | tsConfig: '/tsconfig.json', 16 | }, 17 | }, 18 | transform: { 19 | '^.+\\.(ts|tsx|js|jsx)?$': 'ts-jest', 20 | }, 21 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 22 | modulePaths: ['/node_modules'], 23 | }; 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "monorepo", 3 | "private": true, 4 | "workspaces": [ 5 | "demo", 6 | "react-dvd-screensaver" 7 | ], 8 | "scripts": { 9 | "start:demo": "npm run dev -w demo", 10 | "start:package": "npm run dev -w react-dvd-screensaver", 11 | "build:demo": "npm run build -w demo", 12 | "build:package": "npm run build -w react-dvd-screensaver", 13 | "test": "jest --passWithNoTests" 14 | }, 15 | "devDependencies": { 16 | "@testing-library/jest-dom": "^6.1.3", 17 | "@testing-library/react": "^14.0.0", 18 | "@types/jest": "^29.5.5", 19 | "jest": "^29.7.0", 20 | "jest-environment-jsdom": "^29.7.0", 21 | "ts-jest": "^29.1.1", 22 | "ts-node": "^10.9.1", 23 | "tsup": "^8.0.1", 24 | "typescript": "^5.0.2" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /react-dvd-screensaver/README.md: -------------------------------------------------------------------------------- 1 | # React DVD Screensaver 2 | 3 | [![TypeScript](https://badges.frapsoft.com/typescript/code/typescript.svg?v=101)](https://github.com/ellerbrock/typescript-badges/) 4 | 5 | DVD-era nostalgia in React. 6 | 7 | [Demo](https://samuel.weckstrom.xyz/react-dvd-screensaver) 8 | 9 | [Try the project on Stackblitz](https://stackblitz.com/~/github.com/samuelweckstrom/react-dvd-screensaver) 10 | 11 |
12 | 13 | ``` 14 | npm i react-dvd-screensaver 15 | ``` 16 | 17 |
18 | 19 | ## Use hook 20 | 21 | To add a DVD screensaver effect to your React components, you can use the useDvdScreensaver hook. This hook provides you with references for both the parent (container) and the child (moving element), along with the number of times the child has hit the edges of the container. 22 |
23 | 24 | ```typescript 25 | import { useDvdScreensaver } from 'react-dvd-screensaver' 26 | 27 | ... 28 | 29 | const { containerRef, elementRef } = useDvdScreensaver(); 30 | 31 | return ( 32 |
33 | 34 |
35 | ) 36 | 37 | ``` 38 | 39 | Pass the `ref` objects for parent and child to their respective components. Just remember to set the dimensions for both of them, where the `childRef` component naturally is smaller than the parent so there is room for it to move around. 40 | 41 | | Hook returns following:|| 42 | | ------------- | ------------- | 43 | |`parentRef: refObject`| Ref of the parent container component| 44 | |`elementRef: refObject`| Ref of the element to animate| 45 | |`impactCount: number`| Number of times the element has hit the container edges| 46 | 47 |
48 | 49 | ### Hook Options 50 | The hook accepts the following parameters: 51 | 52 | 53 | | Option | Type | Description | 54 | | ------------- | -------|-----| 55 | |`speed`| `number` | Speed of the animation | 56 | |`freezeOnHover`| `boolean` | Whether to pause the animation on hover | 57 | |`hoverCallback`| `Function` | Callback function triggered on hover | 58 | 59 |
60 | 61 | ## Component 62 | 63 | For easier implementation, you can also use the DvdScreensaver component to wrap any child component with the DVD screensaver effect. 64 | 65 |
66 | 67 | ```typescript 68 | import { DvdScreensaver } from 'react-dvd-screensaver' 69 | 70 | ... 71 | 72 | return ( 73 |
74 | 75 | 76 | 77 |
78 | ) 79 | ``` 80 | 81 | The component inherits the parent container's dimensions by default, but you can also specify dimensions or styling directly via props. 82 | 83 |
84 | 85 | ### Props 86 | 87 | | Prop | Type | Description | 88 | | ------------- | ------| -----| 89 | |`className`| `string` | Optional CSS class for the container | 90 | |`freezeOnHover`| `boolean` | Pause animation when hovered | 91 | |`height`| `number` | Optional height for the container | 92 | |`width`| `number` | Optional width for the container | 93 | |`hoverCallback|`Function` | Callback function triggered on hover | 94 | |`impactCallback`|`(count: number) => void`|Callback function triggered on impact with edges | 95 | |`speed`|`number`| Speed of the animation | 96 | 97 | ## License 98 | 99 | [MIT](LICENSE) 100 | -------------------------------------------------------------------------------- /react-dvd-screensaver/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-dvd-screensaver", 3 | "description": "DVD-era nostalgia in React", 4 | "version": "0.1.1", 5 | "main": "dist/index.js", 6 | "files": [ 7 | "dist" 8 | ], 9 | "types": "./dist/index.d.ts", 10 | "typings": "./dist/index.d.ts", 11 | "repository": "git@github.com:samuelweckstrom/react-dvd-screensaver.git", 12 | "author": "Samuel Weckström", 13 | "license": "MIT", 14 | "peerDependencies": { 15 | "react": "^16.3 || ^17.0 || ^18.0 || ^19.0", 16 | "react-dom": "^16.3 || ^17.0 || ^18.0 || ^19.0" 17 | }, 18 | "sideEffects": false, 19 | "type": "module", 20 | "exports": { 21 | ".": "./dist/index.js" 22 | }, 23 | "scripts": { 24 | "dev": "tsup --watch", 25 | "build": "tsup" 26 | }, 27 | "keywords": [ 28 | "react", 29 | "dvd", 30 | "screensaver", 31 | "marquee", 32 | "nostalgia" 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /react-dvd-screensaver/src/DvdScreensaver.tsx: -------------------------------------------------------------------------------- 1 | import React, { cloneElement, useEffect } from 'react'; 2 | import { useDvdScreensaver } from './useDvdScreensaver'; 3 | 4 | /** 5 | * Props for the DvdScreensaver component. 6 | * @typedef Props 7 | */ 8 | export type Props = { 9 | /** @property {React.ReactElement} children - The child element to which the screensaver effect will be applied. */ 10 | children: React.ReactElement>; 11 | /** @property {string} [className] - Optional CSS class name for the container div. */ 12 | className?: string; 13 | /** @property {boolean} [freezeOnHover] - If true, the animation will pause when the mouse hovers over the element. */ 14 | freezeOnHover?: boolean; 15 | /** @property {string} [height] - Optional height for the container, defaults to 100% if not specified. */ 16 | height?: string; 17 | /** @property {() => void} [hoverCallback] - Optional callback function that is called when the element is hovered. */ 18 | hoverCallback?: () => void; 19 | /** @property {(count: number) => void} [impactCallback] - Optional callback function that is called when the animated element hits a boundary. Receives the total number of impacts as an argument. */ 20 | impactCallback?: (count: number) => void; 21 | /** @property {number} [speed] - Optional speed of the animation. Higher values increase the speed. */ 22 | speed?: number; 23 | /** @property {React.CSSProperties} [style] - Optional inline styles for the container. */ 24 | style?: React.CSSProperties; 25 | /** @property {string} [width] - Optional width for the container, defaults to 100% if not specified. */ 26 | width?: string; 27 | }; 28 | 29 | export function DvdScreensaver(props: Props) { 30 | const { elementRef, impactCount } = useDvdScreensaver({ 31 | freezeOnHover: props.freezeOnHover, 32 | hoverCallback: props.hoverCallback, 33 | speed: props.speed, 34 | }); 35 | 36 | useEffect(() => { 37 | if (props.impactCallback) { 38 | props.impactCallback(impactCount); 39 | } 40 | }, [impactCount, props.impactCallback]); 41 | 42 | const enhancedStyle: React.CSSProperties = { 43 | ...props.style, 44 | width: props?.width || '100%', 45 | height: props?.height || '100%', 46 | }; 47 | 48 | const childWithRef = cloneElement(props.children, { ref: elementRef }); 49 | 50 | return ( 51 |
52 | {childWithRef} 53 |
54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /react-dvd-screensaver/src/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './DvdScreensaver'; 2 | export * from './useDvdScreensaver'; 3 | -------------------------------------------------------------------------------- /react-dvd-screensaver/src/useDvdScreensaver.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useLayoutEffect, useRef, useState } from 'react'; 2 | import { useWindowSize } from './utils'; 3 | 4 | /** 5 | * Configuration options for the DVD screensaver behavior. 6 | */ 7 | export type Options = { 8 | /** Whether the animation should pause when the element is hovered. */ 9 | freezeOnHover?: boolean; 10 | /** Callback function to execute when the element is hovered. */ 11 | hoverCallback?: () => void; 12 | /** Speed of the animation. */ 13 | speed?: number; 14 | }; 15 | 16 | type AnimationRef = { 17 | animationFrameId: number; 18 | containerHeight: number; 19 | containerWidth: number; 20 | impactCount: number; 21 | isPosXIncrement: boolean; 22 | isPosYIncrement: boolean; 23 | positionX: number; 24 | positionY: number; 25 | }; 26 | 27 | /** 28 | * The return type of the useDvdScreensaver hook, providing refs and state for external use. 29 | */ 30 | export type UseDvdScreensaver = { 31 | /** Ref for the container element. */ 32 | containerRef: React.Ref; 33 | /** Ref for the animated element. */ 34 | elementRef: React.Ref; 35 | /** State indicating if the element is currently hovered. */ 36 | hovered: boolean; 37 | /** The total number of boundary impacts made by the animated element. */ 38 | impactCount: number; 39 | }; 40 | 41 | /** 42 | * Hook to handle DVD screensaver animation 43 | * @param options Configuration options for the screensaver animation. 44 | * @returns An object containing refs to the container and element, as well as state. 45 | */ 46 | export function useDvdScreensaver( 47 | options?: Partial 48 | ): UseDvdScreensaver { 49 | const { width: windowWidth } = useWindowSize(); 50 | const animationRef = useRef({ 51 | animationFrameId: 0, 52 | containerHeight: 0, 53 | containerWidth: 0, 54 | impactCount: 0, 55 | isPosXIncrement: true, 56 | isPosYIncrement: true, 57 | positionX: Math.random() * windowWidth, 58 | positionY: Math.random() * windowWidth, 59 | }); 60 | const elementRef = useRef(null); 61 | const containerRef = useRef(null); 62 | const [impactCount, setImpactCount] = useState(0); 63 | const [hovered, setHovered] = useState(false); 64 | 65 | function updatePosition( 66 | containerSpan: number, 67 | delta: number, 68 | elementSpan: number, 69 | prevPos: number, 70 | toggleRefKey: 'isPosXIncrement' | 'isPosYIncrement' 71 | ): number { 72 | const parentBoundary = containerSpan - elementSpan; 73 | let newPos = 74 | prevPos + (animationRef.current[toggleRefKey] ? delta : -delta); 75 | if (newPos <= 0 || newPos >= parentBoundary) { 76 | animationRef.current[toggleRefKey] = !animationRef.current[toggleRefKey]; 77 | animationRef.current.impactCount += 1; 78 | setImpactCount(animationRef.current.impactCount); 79 | newPos = newPos <= 0 ? 0 : parentBoundary; 80 | } 81 | return newPos; 82 | } 83 | 84 | function animate() { 85 | if (elementRef.current && elementRef.current.parentElement) { 86 | const containerHeight = elementRef.current.parentElement.clientHeight; 87 | const containerWidth = elementRef.current.parentElement.clientWidth; 88 | const elementHeight = elementRef.current.clientHeight; 89 | const elementWidth = elementRef.current.clientWidth; 90 | const delta = options?.speed || 2; 91 | 92 | const posX = updatePosition( 93 | containerWidth, 94 | delta, 95 | elementWidth, 96 | animationRef.current.positionX, 97 | 'isPosXIncrement' 98 | ); 99 | const posY = updatePosition( 100 | containerHeight, 101 | delta, 102 | elementHeight, 103 | animationRef.current.positionY, 104 | 'isPosYIncrement' 105 | ); 106 | 107 | elementRef.current.style.transform = `translate3d(${posX}px, ${posY}px, 0)`; 108 | animationRef.current.positionX = posX; 109 | animationRef.current.positionY = posY; 110 | } 111 | 112 | animationRef.current.animationFrameId = requestAnimationFrame(animate); 113 | } 114 | 115 | useEffect(() => { 116 | if (options?.freezeOnHover) { 117 | if (hovered) { 118 | cancelAnimationFrame(animationRef.current.animationFrameId); 119 | animationRef.current.animationFrameId = 0; 120 | } 121 | if (!hovered && !animationRef.current.animationFrameId) { 122 | animationRef.current.animationFrameId = requestAnimationFrame(animate); 123 | } 124 | } 125 | if (options?.hoverCallback) { 126 | options.hoverCallback(); 127 | } 128 | }, [hovered, options]); 129 | 130 | useLayoutEffect(() => { 131 | const element = elementRef.current; 132 | const handleMouseOver = () => setHovered(true); 133 | const handleMouseOut = () => setHovered(false); 134 | 135 | if (element) { 136 | element.style.willChange = 'transform'; 137 | element.addEventListener('mouseover', handleMouseOver); 138 | element.addEventListener('mouseout', handleMouseOut); 139 | animationRef.current.animationFrameId = requestAnimationFrame(animate); 140 | } 141 | 142 | return () => { 143 | if (element) { 144 | element.removeEventListener('mouseover', handleMouseOver); 145 | element.removeEventListener('mouseout', handleMouseOut); 146 | } 147 | cancelAnimationFrame(animationRef.current.animationFrameId); 148 | }; 149 | }, []); 150 | 151 | return { 152 | containerRef, 153 | elementRef, 154 | hovered, 155 | impactCount, 156 | }; 157 | } 158 | -------------------------------------------------------------------------------- /react-dvd-screensaver/src/utils.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export function useWindowSize(): { [T: string]: number } { 4 | const getSize = (): { [T: string]: number } => ({ 5 | width: window.innerWidth, 6 | height: window.innerHeight, 7 | }); 8 | const [windowSize, setWindowSize] = React.useState(getSize); 9 | React.useLayoutEffect(() => { 10 | const onResize = () => setWindowSize(getSize); 11 | window.addEventListener('resize', onResize); 12 | return (): void => window.removeEventListener('resize', onResize); 13 | }, []); 14 | return windowSize; 15 | } 16 | -------------------------------------------------------------------------------- /react-dvd-screensaver/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "alwaysStrict": true, 5 | "declaration": true, 6 | "esModuleInterop": true, 7 | "inlineSourceMap": true, 8 | "jsx": "react", 9 | "lib": [ 10 | "es5", 11 | "es2015", 12 | "es2016", 13 | "es2017", 14 | "es2018", 15 | "es2019", 16 | "es2020", 17 | "DOM" 18 | ], 19 | "module": "esnext", 20 | "moduleResolution": "Bundler", 21 | "noFallthroughCasesInSwitch": true, 22 | "noImplicitAny": true, 23 | "noImplicitReturns": true, 24 | "noImplicitThis": true, 25 | "noUnusedLocals": false, 26 | "noUnusedParameters": false, 27 | "outDir": "./dist", 28 | "skipLibCheck": true, 29 | "strict": false, 30 | "strictBindCallApply": true, 31 | "strictFunctionTypes": true, 32 | "strictNullChecks": true, 33 | "strictPropertyInitialization": true, 34 | "target": "es2015" 35 | }, 36 | "exclude": [ 37 | "dist", 38 | "node_modules" 39 | ], 40 | "include": [ 41 | "./src/*" 42 | ] 43 | } -------------------------------------------------------------------------------- /react-dvd-screensaver/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | entry: ["src/index.tsx"], 5 | treeshake: true, 6 | sourcemap: false, 7 | clean: true, 8 | dts: true, 9 | splitting: false, 10 | format: ["esm"], 11 | external: ["react"], 12 | injectStyle: false, 13 | skipNodeModulesBundle: true, 14 | }); 15 | -------------------------------------------------------------------------------- /setupTests.ts: -------------------------------------------------------------------------------- 1 | require('@testing-library/jest-dom/extend-expect'); 2 | const tsc = require('typescript'); 3 | const tsConfig = require('./tsconfig.json'); 4 | 5 | module.exports = { 6 | process(src: string, path: string): string { 7 | if ( 8 | path.endsWith('.ts') || 9 | path.endsWith('.tsx') || 10 | path.endsWith('.js') || 11 | path.endsWith('.jsx') 12 | ) { 13 | return tsc.transpile(src, tsConfig.compilerOptions, path, []); 14 | } 15 | return src; 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "alwaysStrict": true, 4 | "declaration": true, 5 | "esModuleInterop": true, 6 | "jsx": "react", 7 | "module": "es6", 8 | "moduleResolution": "node", 9 | "noFallthroughCasesInSwitch": true, 10 | "noImplicitAny": true, 11 | "noImplicitReturns": true, 12 | "noImplicitThis": true, 13 | "noUnusedLocals": false, 14 | "noUnusedParameters": false, 15 | "outDir": "./dist", 16 | "sourceMap": true, 17 | "skipLibCheck": true, 18 | "strict": true, 19 | "strictBindCallApply": true, 20 | "strictFunctionTypes": true, 21 | "strictNullChecks": true, 22 | "strictPropertyInitialization": true, 23 | "target": "es5", 24 | "lib": [ 25 | "es5", 26 | "es2015", 27 | "es2016", 28 | "es2017", 29 | "es2018", 30 | "es2019", 31 | "es2020", 32 | "DOM" 33 | ] 34 | }, 35 | "include": [ 36 | "./src/**/*" 37 | ], 38 | "exclude": [ 39 | "./src/__tests__", 40 | "node_modules" 41 | ] 42 | } --------------------------------------------------------------------------------