├── .babelrc ├── .eslint ├── .gitignore ├── LICENSE ├── README.md ├── example ├── .babelrc ├── package.json ├── server │ └── index.js ├── src │ ├── components │ │ ├── App.js │ │ ├── Example1.js │ │ ├── Example2.js │ │ ├── Example3.js │ │ ├── Example4.js │ │ └── Section.js │ ├── index.js │ └── styles │ │ └── reset.css ├── static │ └── index.html └── webpack.config.js ├── package.json └── src ├── Manager.js ├── ScrollableAnchor.js ├── index.js └── utils ├── func.js ├── hash.js └── scroll.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-0", "react"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslint: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb/base"], 3 | "parserOptions": { 4 | "ecmaVersion": 7, 5 | "sourceType": "module", 6 | "ecmaFeatures": { 7 | "impliedStrict": true, 8 | "jsx": true 9 | } 10 | }, 11 | "ecmaFeatures": { 12 | "experimentalObjectRestSpread": true 13 | }, 14 | "env": { 15 | "mocha": true, 16 | "browser": true, 17 | "es6": true 18 | }, 19 | "globals": { 20 | "React": true, 21 | }, 22 | "parser": "babel-eslint", 23 | "plugins": [ 24 | "react" 25 | ], 26 | "rules": { 27 | "comma-dangle": 0, 28 | "semi": 0, 29 | "object-curly-spacing": 0, 30 | "padded-blocks": 0, 31 | "no-param-reassign": 0, 32 | "react/jsx-uses-react": 1, 33 | "react/jsx-uses-vars": 1, 34 | "react/react-in-jsx-scope": 1, 35 | "import/no-unresolved": 0, 36 | "import/extensions": 0, 37 | "class-methods-use-this": 0 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | *.log 4 | lib 5 | dist 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Gabe G'Sell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | react-scrollable-anchor 2 | ===================== 3 | 4 | [![npm version](https://img.shields.io/npm/v/react-scrollable-anchor.svg?style=flat-square)](https://www.npmjs.com/package/react-scrollable-anchor) 5 | 6 | Lightweight library for smooth scrolling anchors in React, tied to URL hash. 7 | 8 | * Land on correct anchor when page is loaded, based on URL hash value. 9 | * Scroll smoothly to anchors when URL hash changes. Easy links to sections with ``. 10 | * URL hash updates automatically to reflect section in view 11 | * Option to record history on hash changes 12 | 13 | ```js 14 | npm install --save react-scrollable-anchor 15 | ``` 16 | 17 | ## Examples 18 | 19 | [Live Demo](http://gabegsell.com/anchors/) or [Source](https://github.com/gabergg/react-scrollable-anchor/tree/master/example/src/components) 20 | 21 | To run examples locally, `npm run example`, then open your 22 | browser to localhost:3210. 23 | 24 | ## Usage 25 | 26 | ### 1. Creating a scrollable anchor 27 | 28 | Use the `ScrollableAnchor` tag to wrap any React element, making it a scrollable anchor. 29 | 30 | ```js 31 | import React, { Component } from 'react' 32 | import ScrollableAnchor from 'react-scrollable-anchor' 33 | 34 | export default class Page extends Component { 35 | render() { 36 | return ( 37 |
38 | Go to section 1 39 | Go to section 2 40 | 41 |
Hello World
42 |
43 | 44 |
How are you world?
45 |
46 |
47 | ) 48 | } 49 | } 50 | ``` 51 | 52 | ### 2. Configure 53 | 54 | Access configureAnchors to customize scrolling and anchors. 55 | 56 | ##### Offset all scrollable anchors by a fixed amount 57 | 58 | ```js 59 | import { configureAnchors } from 'react-scrollable-anchor' 60 | 61 | // Offset all anchors by -60 to account for a fixed header 62 | // and scroll more quickly than the default 400ms 63 | configureAnchors({offset: -60, scrollDuration: 200}) 64 | ``` 65 | 66 | ##### Options: 67 | 68 | | option | default | 69 | | -------------------- | ---------------- | 70 | | `offset` | `0` | 71 | | `scrollDuration` | `400` | 72 | | `keepLastAnchorHash` | `false` | 73 | 74 | ### 3. Utilities 75 | 76 | A small toolkit of scrolling utilies for use with anchors 77 | 78 | ##### Jump to top of page in a way that plays nicely with scrollable anchors 79 | 80 | ```js 81 | import { goToTop } from 'react-scrollable-anchor' 82 | 83 | // scroll to top of the page 84 | goToTop() 85 | ``` 86 | 87 | ##### Scroll to any scrollable anchor, with option to record history 88 | 89 | ```js 90 | import { goToAnchor } from 'react-scrollable-anchor' 91 | 92 | // scroll to #section1 without saving that hash update in history 93 | goToAnchor('section1') 94 | goToAnchor('section1', false) 95 | 96 | // scroll to #section1, saving that hash update in history 97 | goToAnchor('section1', true) 98 | ``` 99 | 100 | ##### Clear the URL hash without affecting scroll location at all 101 | 102 | ```js 103 | import { removeHash } from 'react-scrollable-anchor' 104 | 105 | // clear URL hash 106 | removeHash() 107 | ``` 108 | 109 | ## Issues and feature requests 110 | 111 | Please open issues on [Github](https://github.com/gabergg/react-scrollable-anchor/issues). Issues are easier to address if you include context and code samples. 112 | 113 | ## Contributing 114 | 115 | Please contribute! 116 | 117 | ## Feedback or contact 118 | 119 | Feel free to contact me at gabergg@gmail.com. 120 | -------------------------------------------------------------------------------- /example/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["es2015", {"modules": false}], 4 | "stage-0", 5 | "react" 6 | ], 7 | "plugins": [ 8 | "react-hot-loader/babel", 9 | "transform-runtime", 10 | "transform-decorators-legacy" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-scrollable-anchor-example", 3 | "description": "Example for react-scrollable-anchor", 4 | "scripts": { 5 | "start": "webpack-dev-server --env.dev", 6 | "build": "./node_modules/webpack/bin/webpack.js", 7 | "clean": "rm -rf dist" 8 | }, 9 | "dependencies": { 10 | "compression": "^1.6.2", 11 | "express": "^4.14.0", 12 | "react": "15.3.0", 13 | "react-dom": "15.3.0" 14 | }, 15 | "devDependencies": { 16 | "babel-core": "6.13.2", 17 | "babel-loader": "6.2.4", 18 | "babel-plugin-transform-decorators-legacy": "^1.3.4", 19 | "babel-plugin-transform-runtime": "^6.15.0", 20 | "babel-preset-es2015": "6.13.2", 21 | "babel-preset-react": "6.11.1", 22 | "babel-preset-stage-0": "^6.16.0", 23 | "copy-webpack-plugin": "^4.0.1", 24 | "css-loader": "0.23.1", 25 | "postcss-loader": "0.9.1", 26 | "raw-loader": "^0.5.1", 27 | "react-hot-loader": "3.0.0-beta.3", 28 | "style-loader": "0.13.1", 29 | "webpack": "2.1.0-beta.20", 30 | "webpack-dev-server": "2.1.0-beta.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /example/server/index.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const express = require('express') 3 | const compression = require('compression') 4 | 5 | const port = process.env.PORT || 3210 6 | 7 | const paths = { 8 | index: path.resolve(__dirname, '..', 'dist', 'index.html'), 9 | dist: path.resolve(__dirname, '..', 'dist'), 10 | } 11 | 12 | const app = express() 13 | 14 | app.use(compression()) 15 | 16 | app.use(express.static(paths.dist)) 17 | 18 | app.get('*', (req, res) => res.sendFile(paths.index)) 19 | 20 | app.listen(port, () => console.log(`Server started on port: ${port}`)) 21 | -------------------------------------------------------------------------------- /example/src/components/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import Example1 from './Example1' 3 | import Example2 from './Example2' 4 | import Example3 from './Example3' 5 | import Example4 from './Example4' 6 | import ScrollableAnchor, { goToTop, goToAnchor, removeHash } from '../../../src' 7 | 8 | const examples = [ 9 | {id: 'example1', label: 'Example 1', component: Example1}, 10 | {id: 'example2', label: 'Example 2', component: Example2}, 11 | {id: 'example3', label: 'Example 3', component: Example3}, 12 | {id: 'example4', label: 'Example 4', component: Example4}, 13 | ] 14 | 15 | const styles = { 16 | header: { 17 | zIndex: 1, 18 | backgroundColor: 'rgb(235, 235, 235)', 19 | height: '60px', 20 | display: 'flex', 21 | flexDirection: 'row', 22 | justifyContent: 'space-between', 23 | alignItems: 'center', 24 | padding: '0 85px', 25 | }, 26 | fixed: { 27 | position: 'fixed', 28 | top: 0, 29 | right: 0, 30 | left: 0, 31 | }, 32 | headerToggle: { 33 | marginRight: '15px', 34 | cursor: 'pointer', 35 | }, 36 | exampleToggles: { 37 | display: 'flex', 38 | flexDirection: 'row', 39 | }, 40 | sectionNav: { 41 | display: 'flex', 42 | flexDirection: 'row', 43 | }, 44 | singleSectionNav: { 45 | marginLeft: '15px', 46 | }, 47 | link: { 48 | textDecoration: 'none', 49 | color: 'black', 50 | cursor: 'pointer', 51 | }, 52 | selectedToggle: { 53 | backgroundColor: 'black', 54 | color: 'white', 55 | } 56 | } 57 | 58 | export default class App extends Component { 59 | 60 | state = { 61 | exampleIdx: 0, 62 | } 63 | 64 | renderExampleToggle = (example, index) => { 65 | let toggleStyle = styles.headerToggle 66 | if (this.state.exampleIdx === index) { 67 | toggleStyle = {...toggleStyle, ...styles.selectedToggle} 68 | } 69 | return ( 70 |
this.toggleExample(index)}> 71 | {example.label} 72 |
73 | 74 | ) 75 | } 76 | 77 | renderSectionNav = (section) => { 78 | return ( 79 |
80 | {section.label} 81 |
82 | ) 83 | } 84 | 85 | renderSectionNavHelper = (section) => { 86 | return ( 87 |
88 | goToAnchor(section.id)} 91 | > 92 | {section.label} 93 | 94 |
95 | ) 96 | } 97 | 98 | renderHeader = (fixed, sections, useHelpers) => { 99 | const headerStyle = fixed ? {...styles.header, ...styles.fixed} : styles.header 100 | const sectionRender = useHelpers ? this.renderSectionNavHelper : this.renderSectionNav 101 | return ( 102 |
103 |
104 | { examples.map(this.renderExampleToggle) } 105 |
106 |
107 | { sections.map(sectionRender) } 108 |
109 |
110 | ) 111 | } 112 | 113 | toggleExample = (index) => { 114 | if (this.state.exampleIdx !== index) { 115 | removeHash() 116 | this.setState({exampleIdx: index}, goToTop) 117 | } 118 | } 119 | 120 | render() { 121 | const ExampleContent = examples[this.state.exampleIdx].component 122 | 123 | return ( 124 |
125 | 126 |
127 | ) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /example/src/components/Example1.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import ScrollableAnchor, { configureAnchors } from '../../../src' 3 | import Section from './Section' 4 | 5 | const sections = [ 6 | {id: 'section1', label: 'Section 1', backgroundColor: 'red'}, 7 | {id: 'section2', label: 'Section 2', backgroundColor: 'darkgray'}, 8 | {id: 'section3', label: 'Section 3', backgroundColor: 'green'}, 9 | {id: 'section4', label: 'Section 4', backgroundColor: 'brown'}, 10 | {id: 'section5', label: 'Section 5', backgroundColor: 'lightpink'}, 11 | ] 12 | 13 | export default class Example1 extends Component { 14 | 15 | componentWillMount() { 16 | configureAnchors({}) 17 | } 18 | 19 | renderSection = (section) => { 20 | const props = {...section, sections} 21 | 22 | return ( 23 |
24 | 25 |
26 | 27 |
28 |
29 | ) 30 | } 31 | 32 | render() { 33 | return ( 34 |
35 | { this.props.renderHeader(false, sections) } 36 |
37 | { sections.map(this.renderSection) } 38 |
39 |
40 | ) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /example/src/components/Example2.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import ScrollableAnchor, { configureAnchors } from '../../../src' 3 | import Section from './Section' 4 | 5 | const sections = [ 6 | {id: 'section1', label: 'Section 1', backgroundColor: 'red'}, 7 | {id: 'section2', label: 'Section 2', backgroundColor: 'darkgray'}, 8 | {id: 'section3', label: 'Section 3', backgroundColor: 'green'}, 9 | {id: 'section4', label: 'Section 4', backgroundColor: 'brown'}, 10 | {id: 'section5', label: 'Section 5', backgroundColor: 'lightpink'}, 11 | ] 12 | 13 | export default class Example2 extends Component { 14 | 15 | componentWillMount() { 16 | configureAnchors({offset: -60, scrollDuration: 200}) 17 | } 18 | 19 | renderSection = (section) => { 20 | const props = {...section, sections} 21 | return ( 22 | 23 |
24 | 25 | ) 26 | } 27 | 28 | render() { 29 | return ( 30 |
31 | { this.props.renderHeader(true, sections) } 32 |
33 | { sections.map(this.renderSection) } 34 |
35 |
36 | ) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /example/src/components/Example3.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import ScrollableAnchor, { configureAnchors } from '../../../src' 3 | import Section from './Section' 4 | 5 | const sections = [ 6 | {id: 'section1', label: 'Section 1', backgroundColor: 'red'}, 7 | {id: 'section2', label: 'Section 2', backgroundColor: 'darkgray'}, 8 | {id: 'section3', label: 'Section 3', backgroundColor: 'green'}, 9 | {id: 'section4', label: 'Section 4', backgroundColor: 'brown'}, 10 | {id: 'section5', label: 'Section 5', backgroundColor: 'lightpink'}, 11 | ] 12 | 13 | const styles = { 14 | centerColumn: { 15 | display: 'flex', 16 | flexDirection: 'column', 17 | justifyContent: 'center', 18 | } 19 | } 20 | 21 | export default class Example3 extends Component { 22 | 23 | componentWillMount() { 24 | configureAnchors({offset: -60, scrollDuration: 200}) 25 | } 26 | 27 | renderSection = (section) => { 28 | const props = {...section, sections} 29 | return ( 30 |
31 | 32 |
33 | 34 |
35 | 36 |
37 | 38 |
39 |
40 |
41 |
42 |
43 |
44 | ) 45 | } 46 | 47 | render() { 48 | return ( 49 |
50 | { this.props.renderHeader(true, sections, true) } 51 |
52 | { sections.map(this.renderSection) } 53 |
54 |
55 | ) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /example/src/components/Example4.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import ScrollableAnchor, { configureAnchors } from '../../../src' 3 | import Section from './Section' 4 | 5 | const sections = [ 6 | {id: 'section1', label: 'Section 1', backgroundColor: 'red'}, 7 | {id: 'section2', label: 'Section 2', backgroundColor: 'darkgray'}, 8 | {id: 'section3', label: 'Section 3', backgroundColor: 'green'}, 9 | {id: 'section4', label: 'Section 4', backgroundColor: 'brown'}, 10 | {id: 'section5', label: 'Section 5', backgroundColor: 'lightpink'}, 11 | ] 12 | 13 | const styles = { 14 | offsetUp: { 15 | marginTop: '-549px', 16 | }, 17 | extraTall: { 18 | height: '700px', 19 | }, 20 | } 21 | 22 | export default class Example4 extends Component { 23 | 24 | componentWillMount() { 25 | configureAnchors({offset: -60, scrollDuration: 300}) 26 | } 27 | 28 | renderSection = (section) => { 29 | const props = {...section, sections, style: styles.extraTall} 30 | const propsOffset = {...props, style: styles.offsetUp} 31 | return ( 32 |
33 | 34 |
35 | 36 | 37 |
38 | 39 |
40 | ) 41 | } 42 | 43 | render() { 44 | return ( 45 |
46 | { this.props.renderHeader(true, sections, true) } 47 |
48 | { sections.map(this.renderSection) } 49 |
50 |
51 | ) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /example/src/components/Section.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react' 2 | import { goToTop } from '../../../src' 3 | 4 | const styles = { 5 | container: { 6 | height: '500px', 7 | padding: '25px 85px', 8 | }, 9 | label: { 10 | fontSize: '36px', 11 | }, 12 | link: { 13 | textDecoration: 'none', 14 | color: 'white', 15 | cursor: 'pointer', 16 | }, 17 | } 18 | 19 | export default class Section extends Component { 20 | 21 | static propTypes = { 22 | backgroundColor: PropTypes.string, 23 | label: PropTypes.string, 24 | style: PropTypes.object, 25 | sections: PropTypes.array, 26 | } 27 | 28 | static defaultProps = { 29 | backgroundColor: 'white', 30 | label: 'Section', 31 | style: {}, 32 | sections: [], 33 | } 34 | 35 | renderSectionLink = (section) => { 36 | return ( 37 | 40 | ) 41 | } 42 | 43 | render() { 44 | const {backgroundColor, label, style, sections} = this.props 45 | const containerStyle = {...style, ...styles.container, backgroundColor} 46 | 47 | return ( 48 |
49 |
50 | {label} 51 |
52 | { sections.map(this.renderSectionLink) } 53 |
Top
54 |
55 | ) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /example/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import { AppContainer } from 'react-hot-loader' 4 | import './styles/reset.css' 5 | 6 | import App from './components/App' 7 | 8 | const render = () => { 9 | ReactDOM.render( 10 | 11 | 12 | , 13 | document.getElementById('root') 14 | ) 15 | } 16 | 17 | render() 18 | 19 | // Hot Module Replacement API 20 | if (module.hot) { 21 | module.hot.accept('./components/App', render) 22 | } 23 | -------------------------------------------------------------------------------- /example/src/styles/reset.css: -------------------------------------------------------------------------------- 1 | /* http://meyerweb.com/eric/tools/css/reset/ 2 | v2.0 | 20110126 3 | License: none (public domain) 4 | */ 5 | 6 | html, body, div, span, applet, object, iframe, 7 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 8 | a, abbr, acronym, address, big, cite, code, 9 | del, dfn, em, img, ins, kbd, q, s, samp, 10 | small, strike, strong, sub, sup, tt, var, 11 | b, u, i, center, 12 | dl, dt, dd, ol, ul, li, 13 | fieldset, form, label, legend, 14 | table, caption, tbody, tfoot, thead, tr, th, td, 15 | article, aside, canvas, details, embed, 16 | figure, figcaption, footer, header, hgroup, 17 | menu, nav, output, ruby, section, summary, 18 | time, mark, audio, video { 19 | margin: 0; 20 | padding: 0; 21 | border: 0; 22 | font-size: 100%; 23 | font: inherit; 24 | vertical-align: baseline; 25 | } 26 | /* HTML5 display-role reset for older browsers */ 27 | article, aside, details, figcaption, figure, 28 | footer, header, hgroup, menu, nav, section { 29 | display: block; 30 | } 31 | body { 32 | line-height: 1; 33 | } 34 | ol, ul { 35 | list-style: none; 36 | } 37 | blockquote, q { 38 | quotes: none; 39 | } 40 | blockquote:before, blockquote:after, 41 | q:before, q:after { 42 | content: ''; 43 | content: none; 44 | } 45 | table { 46 | border-collapse: collapse; 47 | border-spacing: 0; 48 | } 49 | -------------------------------------------------------------------------------- /example/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /example/webpack.config.js: -------------------------------------------------------------------------------- 1 | const {resolve} = require('path') 2 | const webpack = require('webpack') 3 | const CopyWebpackPlugin = require('copy-webpack-plugin') 4 | 5 | const paths = { 6 | dist: resolve(__dirname, 'dist'), 7 | src: resolve(__dirname, 'src'), 8 | static: resolve(__dirname, 'static'), 9 | } 10 | 11 | module.exports = (env = {}) => { 12 | const {dev} = env 13 | 14 | const copyPlugin = new CopyWebpackPlugin([ 15 | {from: paths.static, to: paths.dist}, 16 | ]) 17 | 18 | const base = { 19 | output: { 20 | filename: 'bundle.js', 21 | path: paths.dist, 22 | publicPath: '/', 23 | }, 24 | context: paths.src, 25 | module: { 26 | loaders: [ 27 | {test: /\.js$/, loaders: ['babel-loader'], exclude: /node_modules(?!\/react-disqus-thread)/}, 28 | {test: /\.css$/, loaders: ['style-loader', 'css-loader', 'postcss-loader']}, 29 | ], 30 | }, 31 | resolve: { 32 | alias: { 33 | react: resolve(__dirname, 'node_modules', 'react'), 34 | }, 35 | }, 36 | } 37 | 38 | const devConfig = { 39 | entry: [ 40 | 'react-hot-loader/patch', 41 | 'webpack-dev-server/client?http://localhost:3210', 42 | 'webpack/hot/only-dev-server', 43 | './index.js', 44 | ], 45 | devServer: { 46 | hot: true, 47 | contentBase: paths.dist, 48 | outputPath: paths.dist, 49 | publicPath: '/', 50 | historyApiFallback: true, 51 | port: 3210, 52 | }, 53 | devtool: 'inline-source-map', 54 | plugins: [ 55 | copyPlugin, 56 | new webpack.DefinePlugin({ 57 | 'process.env': { 58 | 'NODE_ENV': JSON.stringify('development') 59 | } 60 | }), 61 | new webpack.HotModuleReplacementPlugin(), 62 | new webpack.NamedModulesPlugin(), 63 | ], 64 | } 65 | 66 | const prodConfig = { 67 | entry: [ 68 | './index.js', 69 | ], 70 | plugins: [ 71 | copyPlugin, 72 | new webpack.DefinePlugin({ 73 | 'process.env': { 74 | 'NODE_ENV': JSON.stringify('production'), 75 | }, 76 | }), 77 | new webpack.optimize.DedupePlugin(), 78 | // new webpack.optimize.OccurenceOrderPlugin(), 79 | new webpack.optimize.UglifyJsPlugin({ 80 | compress: { 81 | warnings: false, 82 | }, 83 | }), 84 | ], 85 | } 86 | 87 | return Object.assign(base, dev ? devConfig : prodConfig) 88 | } 89 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-scrollable-anchor", 3 | "version": "0.6.2", 4 | "description": "Provide smooth scrolling anchors in React.", 5 | "main": "lib/index.js", 6 | "files": [ 7 | "lib", 8 | "README.md" 9 | ], 10 | "scripts": { 11 | "clean": "rimraf lib", 12 | "build": "babel src --out-dir lib", 13 | "watch": "babel src --out-dir lib --source-maps inline --watch", 14 | "example": "cd example && npm i; npm run start", 15 | "lint": "eslint src", 16 | "test": "mocha --compilers js:babel/register --recursive src/**/*-test.js", 17 | "prepublish": "npm run clean && npm run build" 18 | }, 19 | "keywords": [ 20 | "react", 21 | "reactjs", 22 | "scroll", 23 | "hash", 24 | "scrollable", 25 | "scrolling", 26 | "anchor", 27 | "anchors", 28 | "single", 29 | "page", 30 | "app", 31 | "static", 32 | "site", 33 | "jump" 34 | ], 35 | "author": "Gabe G'Sell ", 36 | "license": "MIT", 37 | "repository": { 38 | "type": "git", 39 | "url": "https://github.com/gabergg/react-scrollable-anchor.git" 40 | }, 41 | "bugs": { 42 | "url": "https://github.com/gabergg/react-scrollable-anchor/issues" 43 | }, 44 | "homepage": "https://github.com/gabergg/react-scrollable-anchor", 45 | "dependencies": { 46 | "jump.js": "1.0.2", 47 | "prop-types": "^15.5.10" 48 | }, 49 | "peerDependencies": { 50 | "react": "^15.3.0 || ^16.0.0", 51 | "react-dom": "^15.3.0 || ^16.0.0" 52 | }, 53 | "devDependencies": { 54 | "babel-cli": "^6.18.0", 55 | "babel-core": "^6.18.2", 56 | "babel-eslint": "^7.1.1", 57 | "babel-preset-es2015": "^6.18.0", 58 | "babel-preset-react": "^6.16.0", 59 | "babel-preset-stage-0": "^6.16.0", 60 | "eslint": "^3.10.2", 61 | "eslint-config-airbnb": "^13.0.0", 62 | "eslint-plugin-import": "^2.2.0", 63 | "eslint-plugin-jsx-a11y": "^2.2.3", 64 | "eslint-plugin-react": "^6.7.1", 65 | "expect": "^1.13.4", 66 | "mocha": "^2.2.5", 67 | "rimraf": "^2.5.0" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Manager.js: -------------------------------------------------------------------------------- 1 | import jump from 'jump.js' 2 | import { debounce } from './utils/func' 3 | import { getBestAnchorGivenScrollLocation, getScrollTop } from './utils/scroll' 4 | import { getHash, updateHash, removeHash } from './utils/hash' 5 | 6 | const defaultConfig = { 7 | offset: 0, 8 | scrollDuration: 400, 9 | keepLastAnchorHash: false, 10 | } 11 | 12 | class Manager { 13 | constructor() { 14 | this.anchors = {} 15 | this.forcedHash = false 16 | this.config = defaultConfig 17 | 18 | this.scrollHandler = debounce(this.handleScroll, 100) 19 | this.forceHashUpdate = debounce(this.handleHashChange, 1) 20 | } 21 | 22 | addListeners = () => { 23 | window.addEventListener('scroll', this.scrollHandler, false) 24 | window.addEventListener('hashchange', this.handleHashChange) 25 | } 26 | 27 | removeListeners = () => { 28 | window.removeEventListener('scroll', this.scrollHandler, false) 29 | window.removeEventListener('hashchange', this.handleHashChange) 30 | } 31 | 32 | configure = (config) => { 33 | this.config = { 34 | ...defaultConfig, 35 | ...config, 36 | } 37 | } 38 | 39 | goToTop = () => { 40 | if (getScrollTop() === 0) return 41 | this.forcedHash = true 42 | window.scroll(0,0) 43 | } 44 | 45 | addAnchor = (id, component) => { 46 | // if this is the first anchor, set up listeners 47 | if (Object.keys(this.anchors).length === 0) { 48 | this.addListeners() 49 | } 50 | this.forceHashUpdate() 51 | this.anchors[id] = component 52 | } 53 | 54 | removeAnchor = (id) => { 55 | delete this.anchors[id] 56 | // if this is the last anchor, remove listeners 57 | if (Object.keys(this.anchors).length === 0) { 58 | this.removeListeners() 59 | } 60 | } 61 | 62 | handleScroll = () => { 63 | const {offset, keepLastAnchorHash} = this.config 64 | const bestAnchorId = getBestAnchorGivenScrollLocation(this.anchors, offset) 65 | 66 | if (bestAnchorId && getHash() !== bestAnchorId) { 67 | this.forcedHash = true 68 | updateHash(bestAnchorId, false) 69 | } else if (!bestAnchorId && !keepLastAnchorHash) { 70 | removeHash() 71 | } 72 | } 73 | 74 | handleHashChange = (e) => { 75 | if (this.forcedHash) { 76 | this.forcedHash = false 77 | } else { 78 | this.goToSection(getHash()) 79 | } 80 | } 81 | 82 | goToSection = (id) => { 83 | let element = this.anchors[id] 84 | if (element) { 85 | jump(element, { 86 | duration: this.config.scrollDuration, 87 | offset: this.config.offset, 88 | }) 89 | } else { 90 | // make sure that standard hash anchors don't break. 91 | // simply jump to them. 92 | element = document.getElementById(id) 93 | if (element) { 94 | jump(element, { 95 | duration: 0, 96 | offset: this.config.offset, 97 | }) 98 | } 99 | } 100 | } 101 | } 102 | 103 | export default new Manager() 104 | -------------------------------------------------------------------------------- /src/ScrollableAnchor.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import ReactDOM from 'react-dom' 3 | import PropTypes from 'prop-types' 4 | import Manager from './Manager' 5 | 6 | export default class ScrollableAnchor extends Component { 7 | static propTypes = { 8 | children: PropTypes.node, 9 | id: PropTypes.string, 10 | } 11 | 12 | constructor(props) { 13 | super(props) 14 | this.id = props.id || props.children.ref 15 | } 16 | 17 | componentDidMount() { 18 | const element = ReactDOM.findDOMNode(this.refs[Object.keys(this.refs)[0]]) 19 | Manager.addAnchor(this.id, element) 20 | } 21 | 22 | componentWillUnmount() { 23 | Manager.removeAnchor(this.id) 24 | } 25 | 26 | render() { 27 | const {children, id} = this.props 28 | 29 | return React.cloneElement(children, { 30 | ref: children.ref || id, 31 | }) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Manager from './Manager' 2 | export const goToTop = Manager.goToTop 3 | export const configureAnchors = Manager.configure 4 | 5 | export { updateHash as goToAnchor, removeHash } from './utils/hash' 6 | export { default } from './ScrollableAnchor' 7 | -------------------------------------------------------------------------------- /src/utils/func.js: -------------------------------------------------------------------------------- 1 | export const debounce = (func, wait, immediate) => { 2 | let timeout 3 | return () => { 4 | const context = this 5 | const args = arguments 6 | const later = () => { 7 | timeout = null 8 | if (!immediate) { 9 | func.apply(context, args) 10 | } 11 | } 12 | const callNow = immediate && !timeout 13 | clearTimeout(timeout) 14 | timeout = setTimeout(later, wait) 15 | if (callNow) { 16 | func.apply(context, args) 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/utils/hash.js: -------------------------------------------------------------------------------- 1 | export const getHash = () => { 2 | return decodeURI(window.location.hash.slice(1)) 3 | } 4 | 5 | export const updateHash = (hash, affectHistory) => { 6 | if (affectHistory) { 7 | window.location.hash = hash 8 | } else { 9 | window.location.replace(`#${hash}`) 10 | } 11 | } 12 | 13 | // remove hash in url without affecting history or forcing reload 14 | export const removeHash = () => { 15 | history.replaceState( 16 | "", 17 | document.title, 18 | window.location.pathname + window.location.search 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/scroll.js: -------------------------------------------------------------------------------- 1 | export const getScrollTop = () => { 2 | return document.body.scrollTop || document.documentElement.scrollTop 3 | } 4 | 5 | // get vertical offsets of element, taking scrollTop into consideration 6 | export const getElementOffset = (element) => { 7 | const scrollTop = getScrollTop() 8 | const {top, bottom} = element.getBoundingClientRect() 9 | return { 10 | top: Math.floor(top + scrollTop), 11 | bottom: Math.floor(bottom + scrollTop) 12 | } 13 | } 14 | 15 | // does scrollTop live within element bounds? 16 | export const doesElementContainScrollTop = (element, extraOffset = 0) => { 17 | const scrollTop = getScrollTop() 18 | const offsetTop = getElementOffset(element).top + extraOffset 19 | return scrollTop >= offsetTop && scrollTop < offsetTop + element.offsetHeight 20 | } 21 | 22 | // is el2's location more relevant than el2, 23 | // parent-child relationship aside? 24 | export const checkLocationRelevance = (el1, el2) => { 25 | const {top: top1, bottom: bottom1} = getElementOffset(el1) 26 | const {top: top2, bottom: bottom2} = getElementOffset(el2) 27 | if (top1 === top2) { 28 | if (bottom1 === bottom2) { 29 | // top and bottom of compared elements are the same, 30 | // so return one randomly in a deterministic way 31 | return el1 < el2 32 | } 33 | // top of compared elements is the same, so return whichever 34 | // element has its bottom higher on the page 35 | return bottom2 < bottom1 36 | } 37 | // top of compared elements differ, so return true 38 | // if tested element has its top lower on the page 39 | return top2 > top1 40 | } 41 | 42 | // check if el2 is more relevant than el1, considering child-parent 43 | // relationships as well as node location. 44 | export const checkElementRelevance = (el1, el2) => { 45 | if (el1.contains(el2)) { 46 | // el2 is child, so it gains relevance priority 47 | return true 48 | } else if (!el2.contains(el1) && checkLocationRelevance(el1, el2)) { 49 | // el1 and el2 are unrelated, but el2 has a better location, 50 | // so it gains relevance priority 51 | return true 52 | } 53 | return false 54 | } 55 | 56 | // given a set of anchors, find which one is, given the following logic: 57 | // 1. children nodes are more relevant than parent nodes 58 | // 2. if neither node contains the other, and their top locations differ, 59 | // the node with the top lower on the page is more relevant 60 | // 3. if neither node contains the other, and their top locations are the same, 61 | // the node with the bottom higher on the page is more relevant 62 | // 4. if neither node contains the other, and their top and bottom locations 63 | // are the same, a node is chosen at random, in a deterministic way, 64 | // to be more relevant. 65 | export const getBestAnchorGivenScrollLocation = (anchors, offset) => { 66 | let bestId, bestElement 67 | 68 | Object.keys(anchors).forEach((id) => { 69 | const element = anchors[id] 70 | if (doesElementContainScrollTop(element, offset)) { 71 | if (!bestElement || checkElementRelevance(bestElement, element)) { 72 | bestElement = element 73 | bestId = id 74 | } 75 | } 76 | }) 77 | return bestId 78 | } 79 | --------------------------------------------------------------------------------