├── .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 | [](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 |
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 | [](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 | }
--------------------------------------------------------------------------------