├── .gitignore ├── tsconfig.json ├── package.json ├── .github └── workflows │ └── release.yml ├── src └── index.tsx └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | tsconfig.tsbuildinfo 4 | coverage 5 | /test-results/ 6 | /playwright-report/ 7 | /blob-report/ 8 | /playwright/.cache/ 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["./src/**/*.tsx"], 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "lib": ["ESNext", "DOM"], 6 | "module": "ES2022", 7 | "target": "ES2022", 8 | "moduleResolution": "bundler", 9 | "moduleDetection": "force", 10 | "composite": true, 11 | "strict": true, 12 | "downlevelIteration": true, 13 | "jsx": "react-jsx", 14 | "skipLibCheck": true, 15 | "allowSyntheticDefaultImports": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "outDir": "dist", 18 | "declarationDir": "dist" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@epic-web/restore-scroll", 3 | "version": "0.0.0-semantically-released", 4 | "description": "Restore scroll position of elements on page navigation", 5 | "publishConfig": { 6 | "access": "public" 7 | }, 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/epicweb-dev/restore-scroll" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/epicweb-dev/restore-scroll/issues" 14 | }, 15 | "homepage": "https://github.com/epicweb-dev/restore-scroll#readme", 16 | "type": "module", 17 | "main": "./dist/index.js", 18 | "module": "./dist/index.js", 19 | "exports": { 20 | ".": { 21 | "types": "./dist/index.d.ts", 22 | "default": "./dist/index.js" 23 | } 24 | }, 25 | "files": [ 26 | "dist" 27 | ], 28 | "scripts": { 29 | "build": "tsc", 30 | "format": "prettier --write ." 31 | }, 32 | "peerDependencies": { 33 | "react": ">=18.0.0", 34 | "react-router": ">=7.0.0" 35 | }, 36 | "devDependencies": { 37 | "@types/react": "^18.3.12", 38 | "prettier": "^3.4.2", 39 | "react": "^18.3.1", 40 | "react-dom": "^18.3.1", 41 | "react-router": "^7.6.3", 42 | "tsx": "^4.19.2", 43 | "typescript": "^5.7.2" 44 | }, 45 | "prettier": { 46 | "semi": false, 47 | "useTabs": true, 48 | "singleQuote": true, 49 | "proseWrap": "always", 50 | "overrides": [ 51 | { 52 | "files": [ 53 | "**/*.json" 54 | ], 55 | "options": { 56 | "useTabs": false 57 | } 58 | } 59 | ] 60 | }, 61 | "keywords": [], 62 | "author": "Kent C. Dodds (https://kentcdodds.com/)", 63 | "license": "MIT" 64 | } 65 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: [push, pull_request] 3 | 4 | concurrency: 5 | group: ${{ github.workflow }}-${{ github.ref }} 6 | cancel-in-progress: true 7 | 8 | permissions: 9 | contents: write # to be able to publish a GitHub release 10 | id-token: write # to enable use of OIDC for npm provenance 11 | issues: write # to be able to comment on released issues 12 | pull-requests: write # to be able to comment on released pull requests 13 | 14 | jobs: 15 | release: 16 | name: 🚀 Release 17 | runs-on: ubuntu-latest 18 | if: 19 | ${{ github.repository == 'epicweb-dev/restore-scroll' && 20 | contains('refs/heads/main,refs/heads/beta,refs/heads/next,refs/heads/alpha', 21 | github.ref) && github.event_name == 'push' }} 22 | steps: 23 | - name: ⬇️ Checkout repo 24 | uses: actions/checkout@v5 25 | 26 | - name: ⎔ Setup node 27 | uses: actions/setup-node@v6 28 | with: 29 | node-version: lts/* 30 | 31 | - name: 📥 Download deps 32 | uses: bahmutov/npm-install@v1 33 | with: 34 | useLockFile: false 35 | 36 | - name: 📦 Run Build 37 | run: npm run build 38 | 39 | - name: 🚀 Release 40 | uses: cycjimmy/semantic-release-action@v5.0.2 41 | with: 42 | semantic_version: 25 43 | branches: | 44 | [ 45 | '+([0-9])?(.{+([0-9]),x}).x', 46 | 'main', 47 | 'next', 48 | 'next-major', 49 | {name: 'beta', prerelease: true}, 50 | {name: 'alpha', prerelease: true} 51 | ] 52 | env: 53 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 54 | NPM_CONFIG_PROVENANCE: true 55 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { useCallback, useEffect } from 'react' 3 | import { useLocation, useNavigation } from 'react-router' 4 | 5 | type Direction = 'vertical' | 'horizontal' 6 | type ScrollAttribute = 'scrollTop' | 'scrollLeft' 7 | const DIRECTION: { [direction in Direction]: ScrollAttribute } = { 8 | vertical: 'scrollTop', 9 | horizontal: 'scrollLeft', 10 | } 11 | 12 | export function ElementScrollRestoration({ 13 | elementQuery, 14 | direction = 'vertical', 15 | ...props 16 | }: { 17 | elementQuery: string 18 | direction?: Direction 19 | } & React.HTMLProps) { 20 | const STORAGE_KEY = `position:${elementQuery}` 21 | const navigation = useNavigation() 22 | const location = useLocation() 23 | const scrollAttribute = DIRECTION[direction] 24 | 25 | const updatePositions = useCallback(() => { 26 | const element = document.querySelector(elementQuery) 27 | if (!element) return 28 | let positions = {} 29 | try { 30 | const rawPositions = JSON.parse( 31 | sessionStorage.getItem(STORAGE_KEY) || '{}', 32 | ) 33 | if (typeof rawPositions === 'object' && rawPositions !== null) { 34 | positions = rawPositions 35 | } 36 | } catch (error) { 37 | console.warn(`Error parsing scroll positions from sessionStorage:`, error) 38 | } 39 | const newPositions = { 40 | ...positions, 41 | [location.key]: element[scrollAttribute], 42 | } 43 | sessionStorage.setItem(STORAGE_KEY, JSON.stringify(newPositions)) 44 | }, [STORAGE_KEY, elementQuery, location.key]) 45 | 46 | useEffect(() => { 47 | if (navigation.state === 'idle') { 48 | const element = document.querySelector(elementQuery) 49 | if (!element) return 50 | try { 51 | const positions = JSON.parse( 52 | sessionStorage.getItem(STORAGE_KEY) || '{}', 53 | ) as any 54 | const stored = positions[window.history.state.key] 55 | if (typeof stored === 'number') { 56 | element[scrollAttribute] = stored 57 | } 58 | } catch (error: unknown) { 59 | console.error(error) 60 | sessionStorage.removeItem(STORAGE_KEY) 61 | } 62 | } else { 63 | updatePositions() 64 | } 65 | }, [STORAGE_KEY, elementQuery, navigation.state, updatePositions]) 66 | 67 | useEffect(() => { 68 | window.addEventListener('pagehide', updatePositions) 69 | return () => { 70 | window.removeEventListener('pagehide', updatePositions) 71 | } 72 | }, [updatePositions]) 73 | 74 | function restoreScroll( 75 | storageKey: string, 76 | elementQuery: string, 77 | scrollAttribute: ScrollAttribute, 78 | ) { 79 | const element = document.querySelector(elementQuery) 80 | if (!element) { 81 | console.warn(`Element not found: ${elementQuery}. Cannot restore scroll.`) 82 | return 83 | } 84 | if (!window.history.state || !window.history.state.key) { 85 | const key = Math.random().toString(32).slice(2) 86 | window.history.replaceState({ key }, '') 87 | } 88 | try { 89 | const positions = JSON.parse( 90 | sessionStorage.getItem(storageKey) || '{}', 91 | ) as any 92 | const stored = positions[window.history.state.key] 93 | if (typeof stored === 'number') { 94 | element[scrollAttribute] = stored 95 | } 96 | } catch (error: unknown) { 97 | console.error(error) 98 | sessionStorage.removeItem(storageKey) 99 | } 100 | } 101 | return ( 102 |