├── .github └── workflows │ └── publish.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── example.py ├── pyproject.toml ├── streamlit_scroll_navigation ├── __init__.py └── frontend │ ├── .prettierrc │ ├── package-lock.json │ ├── package.json │ ├── public │ ├── CrossOriginInterface.js │ ├── bootstrap.min.css │ └── index.html │ ├── src │ ├── ScrollNavigationBar.tsx │ ├── index.css │ ├── index.tsx │ └── react-app-env.d.ts │ └── tsconfig.json └── tests └── longtest.py /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish streamlit-scroll-navigation to PyPI and TestPyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' # Run workflow on version tags like v1.0.0x` 7 | jobs: 8 | build: 9 | name: Build distribution for streamlit-scroll-navigation 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 # Fetch all history for tags 16 | 17 | - name: Set up Python 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: "3.x" 21 | 22 | - name: Install dependencies from pyproject.toml 23 | run: pip install build 24 | 25 | - name: Set up Node.js 26 | uses: actions/setup-node@v3 27 | with: 28 | node-version: '16.x' # Adjust to your Node version 29 | 30 | - name: Install frontend dependencies 31 | working-directory: ./streamlit_scroll_navigation/frontend # Change if your frontend is in a different directory 32 | run: npm install 33 | 34 | - name: Reset dirty git 35 | run: git reset --hard 36 | 37 | - name: Build frontend 38 | working-directory: ./streamlit_scroll_navigation/frontend 39 | run: npm run build 40 | 41 | - name: Build Python package 42 | run: python3 -m build 43 | 44 | - name: Store the distribution packages 45 | uses: actions/upload-artifact@v4 46 | with: 47 | name: python-package-distributions 48 | path: dist/ 49 | 50 | 51 | pypi-publish: 52 | name: Publish streamlit-scroll-navigation to PyPI 53 | if: startsWith(github.ref, 'refs/tags/') # Only publish on tagged commits and on main branch 54 | needs: build 55 | runs-on: ubuntu-latest 56 | environment: 57 | name: release 58 | url: https://pypi.org/p/streamlit-scroll-navigation/ 59 | permissions: 60 | id-token: write 61 | 62 | steps: 63 | - name: Download distribution packages 64 | uses: actions/download-artifact@v4 65 | with: 66 | name: python-package-distributions 67 | path: dist/ 68 | - name: Publish package distributions to PyPI 69 | uses: pypa/gh-action-pypi-publish@release/v1 70 | 71 | 72 | publish-to-testpypi: 73 | name: Publish streamlit-scroll-navigation to TestPyPI 74 | needs: build 75 | runs-on: ubuntu-latest 76 | environment: 77 | name: testpypi 78 | url: https://test.pypi.org/p/streamlit-scroll-navigation/ 79 | permissions: 80 | id-token: write 81 | 82 | steps: 83 | - name: Download distribution packages 84 | uses: actions/download-artifact@v4 85 | with: 86 | name: python-package-distributions 87 | path: dist/ 88 | - name: Publish package distributions to PyPI 89 | uses: pypa/gh-action-pypi-publish@release/v1 90 | with: 91 | repository-url: https://test.pypi.org/legacy/ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 110 | .pdm.toml 111 | .pdm-python 112 | .pdm-build/ 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | #.idea/ 163 | 164 | 165 | # See http://help.github.com/ignore-files/ for more about ignoring files. 166 | 167 | # compiled output 168 | /dist 169 | /tmp 170 | /out-tsc 171 | 172 | # Runtime data 173 | pids 174 | *.pid 175 | *.seed 176 | *.pid.lock 177 | 178 | # Directory for instrumented libs generated by jscoverage/JSCover 179 | lib-cov 180 | 181 | # Coverage directory used by tools like istanbul 182 | coverage 183 | 184 | # nyc test coverage 185 | .nyc_output 186 | 187 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 188 | .grunt 189 | 190 | # Bower dependency directory (https://bower.io/) 191 | bower_components 192 | 193 | # node-waf configuration 194 | .lock-wscript 195 | 196 | # IDEs and editors 197 | .idea 198 | .project 199 | .classpath 200 | .c9/ 201 | *.launch 202 | .settings/ 203 | *.sublime-workspace 204 | 205 | # IDE - VSCode 206 | .vscode/* 207 | !.vscode/settings.json 208 | !.vscode/tasks.json 209 | !.vscode/launch.json 210 | !.vscode/extensions.json 211 | 212 | # misc 213 | .sass-cache 214 | connect.lock 215 | typings 216 | 217 | # Logs 218 | logs 219 | *.log 220 | npm-debug.log* 221 | yarn-debug.log* 222 | yarn-error.log* 223 | 224 | 225 | # Dependency directories 226 | node_modules/ 227 | jspm_packages/ 228 | 229 | # Optional npm cache directory 230 | .npm 231 | 232 | # Optional eslint cache 233 | .eslintcache 234 | 235 | # Optional REPL history 236 | .node_repl_history 237 | 238 | # Output of 'npm pack' 239 | *.tgz 240 | 241 | # Yarn Integrity file 242 | .yarn-integrity 243 | 244 | # dotenv environment variables file 245 | .env 246 | 247 | # next.js build output 248 | .next 249 | 250 | # Lerna 251 | lerna-debug.log 252 | 253 | # System Files 254 | .DS_Store 255 | Thumbs.db 256 | 257 | # Logs 258 | logs 259 | *.log 260 | npm-debug.log* 261 | yarn-debug.log* 262 | yarn-error.log* 263 | lerna-debug.log* 264 | .pnpm-debug.log* 265 | 266 | # Diagnostic reports (https://nodejs.org/api/report.html) 267 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 268 | 269 | # Runtime data 270 | pids 271 | *.pid 272 | *.seed 273 | *.pid.lock 274 | 275 | # Directory for instrumented libs generated by jscoverage/JSCover 276 | lib-cov 277 | 278 | # Coverage directory used by tools like istanbul 279 | coverage 280 | *.lcov 281 | 282 | # nyc test coverage 283 | .nyc_output 284 | 285 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 286 | .grunt 287 | 288 | # Bower dependency directory (https://bower.io/) 289 | bower_components 290 | 291 | # node-waf configuration 292 | .lock-wscript 293 | 294 | # Compiled binary addons (https://nodejs.org/api/addons.html) 295 | build/Release 296 | 297 | # Dependency directories 298 | *node_modules/ 299 | jspm_packages/ 300 | 301 | # Snowpack dependency directory (https://snowpack.dev/) 302 | web_modules/ 303 | 304 | # TypeScript cache 305 | *.tsbuildinfo 306 | 307 | # Optional npm cache directory 308 | .npm 309 | 310 | # Optional eslint cache 311 | .eslintcache 312 | 313 | # Optional stylelint cache 314 | .stylelintcache 315 | 316 | # Microbundle cache 317 | .rpt2_cache/ 318 | .rts2_cache_cjs/ 319 | .rts2_cache_es/ 320 | .rts2_cache_umd/ 321 | 322 | # Optional REPL history 323 | .node_repl_history 324 | 325 | # Output of 'npm pack' 326 | *.tgz 327 | 328 | # Yarn Integrity file 329 | .yarn-integrity 330 | 331 | # dotenv environment variable files 332 | .env 333 | .env.development.local 334 | .env.test.local 335 | .env.production.local 336 | .env.local 337 | 338 | # parcel-bundler cache (https://parceljs.org/) 339 | .cache 340 | .parcel-cache 341 | 342 | # Next.js build output 343 | .next 344 | out 345 | 346 | # Nuxt.js build / generate output 347 | .nuxt 348 | dist 349 | 350 | # Gatsby files 351 | .cache/ 352 | # Comment in the public line in if your project uses Gatsby and not Next.js 353 | # https://nextjs.org/blog/next-9-1#public-directory-support 354 | # public 355 | 356 | # vuepress build output 357 | .vuepress/dist 358 | 359 | # vuepress v2.x temp and cache directory 360 | .temp 361 | .cache 362 | 363 | # Docusaurus cache and generated files 364 | .docusaurus 365 | 366 | # Serverless directories 367 | .serverless/ 368 | 369 | # FuseBox cache 370 | .fusebox/ 371 | 372 | # DynamoDB Local files 373 | .dynamodb/ 374 | 375 | # TernJS port file 376 | .tern-port 377 | 378 | # Stores VSCode versions used for testing VSCode extensions 379 | .vscode-test 380 | 381 | # yarn v2 382 | .yarn/cache 383 | .yarn/unplugged 384 | .yarn/build-state.yml 385 | .yarn/install-state.gz 386 | .pnp.* -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Nibs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | ============================================================================== 24 | 25 | Copyright (c) 2018-2021 Streamlit Inc. 26 | 27 | Permission is hereby granted, free of charge, to any person obtaining a copy 28 | of this software and associated documentation files (the "Software"), to deal 29 | in the Software without restriction, including without limitation the rights 30 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 31 | copies of the Software, and to permit persons to whom the Software is 32 | furnished to do so, subject to the following conditions: 33 | 34 | The above copyright notice and this permission notice shall be included in all 35 | copies or substantial portions of the Software. 36 | 37 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 38 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 39 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 40 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 41 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 42 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 43 | SOFTWARE. 44 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include streamlit_scroll_navigation/frontend/build * 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # streamlit-scroll-navigation 2 | 3 | This package enables scroll-based navigation for 4 | seamless single-page Streamlit applications. 5 | Use it for portfolios, data stories, or any Streamlit application that presents multiple sections on the same page. It features: 6 | 7 | * **Smooth Animations**: Scrolling to anchors on the page feels fluid and seamless. 8 | * **Anchor tracking**: As the user scrolls, the active anchor automatically updates to the nearest visible anchor. 9 | * **Configurable Icons**: Customize Bootstrap icons for each navigation option to give your app a personal touch. 10 | * **Customizable Styles**: Edit CSS attributes with the override_styles parameter for additional customization. 11 | * **Styled with Bootstrap**: The component comes styled with Bootstrap for a sleek and responsive design. 12 | 13 | Video demo: 14 | 15 | https://github.com/user-attachments/assets/7c353b89-bbc7-4795-80ac-aef0c808c725 16 | 17 | App demo: https://scrollnav-demo.streamlit.app/ 18 | 19 | ## Installation 20 | 21 | ```sh 22 | pip install streamlit-scroll-navigation 23 | ``` 24 | 25 | ## Usage 26 | 27 | `scroll_navbar()` creates a navigation bar with buttons that scroll the page to anchor IDs. It is highly customizable, supporting various orientations, labels, icons, and styles. 28 | 29 | - `anchor_ids` ( `Collection[str]` ): 30 | A collection of anchor IDs that represent the sections or points to navigate to. 31 | **Required**. 32 | 33 | - `key` ( `str`, optional ): 34 | A unique key for the component. Each navbar must have a unique key for cross-origin message handling. 35 | **Default**: `'scroll_navbar_default'`. 36 | 37 | - `anchor_icons` ( `Collection[str]`, optional ): 38 | Icons corresponding to each navigation button. The order of icons provided should correspond to the order of `anchor_ids`. If not provided, no icons will be displayed. 39 | **Default**: `None`. 40 | 41 | - `anchor_labels` ( `Collection[str]`, optional ): 42 | Labels for each navigation button. The order of labels provided should correspond to the order of `anchor_ids`. If not provided, the anchor IDs will be used as labels. 43 | **Default**: `None`. 44 | 45 | - `force_anchor` ( `str`, optional ): 46 | A specific anchor ID to automatically navigate to. This simulates clicking on an anchor. 47 | **Default**: `None`. 48 | 49 | - `orientation` ( `Literal['vertical', 'horizontal']`, optional ): 50 | The orientation of the navigation bar, either `vertical` or `horizontal`. 51 | **Default**: `'vertical'`. 52 | 53 | - `override_styles` ( `Dict[str, str]`, optional ): 54 | A dictionary to override the default styles of the component, allowing further customization. 55 | **Default**: `{}`. 56 | 57 | - `auto_update_anchor` ( `bool`, optional ): 58 | If true, the highlighted anchor will automatically update to the next nearest anchor when the current one is scrolled out of view. 59 | **Default**: `True`. 60 | 61 | - `disable_scroll` (`bool`, optional): 62 | If True, navigation will snap instantly to anchors. 63 | **Default**: `False`. 64 | 65 | ## Examples 66 | 67 | ```python 68 | import streamlit as st 69 | from streamlit_scroll_navigation import scroll_navbar 70 | 71 | # Anchor IDs and icons 72 | anchor_ids = ["About", "Features", "Settings", "Pricing", "Contact"] 73 | anchor_icons = ["info-circle", "lightbulb", "gear", "tag", "envelope"] 74 | 75 | # 1. as sidebar menu 76 | with st.sidebar: 77 | st.subheader("Example 1") 78 | scroll_navbar( 79 | anchor_ids, 80 | anchor_labels=None, # Use anchor_ids as labels 81 | anchor_icons=anchor_icons) 82 | 83 | # 2. horizontal menu 84 | st.subheader("Example 2") 85 | scroll_navbar( 86 | anchor_ids, 87 | key = "navbar2", 88 | anchor_icons=anchor_icons, 89 | orientation="horizontal") 90 | 91 | # Dummy page setup 92 | for anchor_id in anchor_ids: 93 | st.subheader(anchor_id,anchor=anchor_id) 94 | st.write("content " * 100) 95 | ``` 96 | 97 | ### Styles Overrides 98 | The `override_styles` argument allows you to customize the styles for scroll_navbar component. This property accepts an object containing specific style overrides that will be merged with the base styles defined in the component. By using this option, you can modify the appearance of the navigation bar to better fit your design requirements. 99 | 100 | Below is a list of style keys available for customization: 101 | 102 | - `navbarButtonBase`: Base button styling with dark background, white text, pointer cursor, and smooth color transitions. 103 | - `navbarButtonHorizontal` & `navbarButtonVertical`: Orientation-specific properties for horizontal or vertical button alignment. 104 | - `navbarButtonActive`: Style for active anchor buttons. Sets the background color and font weight. 105 | - `navbarButtonHover`: Style for hovered buttons. Sets the background colors and font weight. 106 | - `navigationBarBase`: Core styling for the navigation bar container, setting background, padding, and flexbox layout. 107 | - `navigationBarHorizontal` & `navigationBarVertical`: Orientation-specific properties for the navigation bar. 108 | - `anchorEmphasis`: Style for emphasizing the anchor after a delay. Scales up the element slightly for visual emphasis. 109 | 110 | ## Contributions 111 | 112 | Contributions are welcome! If you’d like to contribute, follow these steps: 113 | 114 | 1. Fork [the repository](https://github.com/SnpM/streamlit-scroll-navigation). 115 | 2. Create a new branch for your feature or bugfix. 116 | 3. Make your changes and commit them with clear messages. 117 | 4. Open a pull request, and provide a detailed description of your changes. 118 | 119 | Feel free to create issues or feature requests as well. 120 | 121 | This component is built on React. 122 | It uses parent DOM injection to enable cross-origin interactions (see [`__init__.py`](https://github.com/SnpM/streamlit-scroll-navigation/blob/main/streamlit_scroll_navigation/__init__.py)). 123 | The API and styles are inspired by victoryhb's [streamlit-option-menu](https://github.com/victoryhb/streamlit-option-menu). 124 | 125 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | # Setup 2 | import streamlit as st 3 | from streamlit_scroll_navigation import scroll_navbar 4 | st.set_page_config(page_title="Scroll Navigation Demo") 5 | 6 | # Anchor IDs and icons 7 | anchor_ids = ["About", "Features", "Settings", "Pricing", "Contact"] 8 | anchor_icons = ["info-circle", "lightbulb", "gear", "tag", "envelope"] 9 | 10 | # 1. vertical menu in sidebar 11 | with st.sidebar: 12 | st.subheader("Example 1", help="Vertical menu in sidebar") 13 | scroll_navbar( 14 | anchor_ids, 15 | anchor_labels=None, # Use anchor_ids as labels 16 | anchor_icons=anchor_icons) 17 | 18 | # 2. horizontal menu 19 | st.subheader("Example 2", help="Horizontal menu") 20 | scroll_navbar( 21 | anchor_ids, 22 | key = "navbar2", 23 | anchor_icons=anchor_icons, 24 | orientation="horizontal") 25 | 26 | # 3. Custom anchor labels with no icons 27 | st.subheader("Example 3", help="Custom anchor labels with no icons") 28 | scroll_navbar( 29 | anchor_ids, 30 | key="navbar3", 31 | anchor_labels=[ 32 | "About Us - We're awesome!", 33 | "Features - Explore our Product", 34 | "Settings - Tailor your Experience", 35 | "Pricing - Spend Money to Make Money", 36 | "Get in Touch - We're here to help" 37 | ], 38 | orientation="horizontal") 39 | 40 | # 4. CSS style definitions 41 | st.subheader("Example 4", help="Custom CSS styles") 42 | scroll_navbar( 43 | anchor_ids=anchor_ids, 44 | key="navbar4", 45 | orientation="horizontal", 46 | override_styles={ 47 | "navbarButtonBase": { 48 | "backgroundColor": "#007bff", # Set a custom button background color 49 | "color": "#ffffff", # Set custom text color 50 | }, 51 | "navbarButtonHover": { 52 | "backgroundColor": "#0056b3", # Set a custom hover color for the buttons 53 | }, 54 | "navigationBarBase": { 55 | "backgroundColor": "#f8f9fa", # Change the navigation bar background color 56 | } 57 | }) 58 | 59 | # 5. Force anchor 60 | st.subheader("Example 5", help="Programatically select an anchor within StreamLit fragment") 61 | @st.fragment 62 | def example5(): 63 | from streamlit_scroll_navigation import ForceAnchor 64 | force_settings = ForceAnchor() 65 | if st.button("Go to Settings"): 66 | force_settings.push("Settings") 67 | scroll_navbar( 68 | anchor_ids, 69 | key="navbar5", 70 | anchor_icons=anchor_icons, 71 | orientation="horizontal", 72 | force_anchor=force_settings) 73 | example5() 74 | 75 | # 6. Retrieve active anchor 76 | with st.sidebar: 77 | st.subheader("Example 6", help="Retrieve active anchor for other Streamlit components") 78 | active_anchor = scroll_navbar( 79 | anchor_ids, 80 | key="navbar6", 81 | orientation="vertical") 82 | st.write(f"{active_anchor} is active in Example 6") 83 | 84 | # 7. Misc: 85 | # Disable automatic active anchor updates, instant scrolling 86 | # Instant snap scrolling 87 | with st.sidebar: 88 | st.subheader("Example 7", help="Instant scrolling; disable anchor tracking") 89 | scroll_navbar( 90 | anchor_ids=anchor_ids, 91 | key="navbar7", 92 | orientation="vertical", 93 | auto_update_anchor=False, 94 | disable_scroll=True) 95 | 96 | # Dummy page setup 97 | for anchor_id in anchor_ids: 98 | st.subheader(anchor_id,anchor=anchor_id) 99 | st.write("content " * 100) -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=42", "wheel", "setuptools_scm[toml]>=8"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "streamlit-scroll-navigation" 7 | dynamic = ["version"] 8 | description = "Streamlit component for scroll-based page navigation." 9 | readme = { file = "README.md", content-type = "text/markdown" } 10 | requires-python = ">=3.7" 11 | license = {text = "MIT"} 12 | authors = [ 13 | { name = "Nibs"} 14 | ] 15 | classifiers = [ 16 | "Development Status :: 5 - Production/Stable", 17 | "Intended Audience :: Developers", 18 | "License :: OSI Approved :: MIT License", 19 | "Programming Language :: Python", 20 | "Programming Language :: Python :: 3", 21 | "Programming Language :: Python :: 3.7", 22 | "Programming Language :: Python :: 3.8", 23 | "Programming Language :: Python :: 3.9", 24 | "Programming Language :: Python :: 3.10", 25 | "Topic :: Software Development :: Libraries", 26 | "Topic :: Software Development :: Libraries :: Python Modules" 27 | ] 28 | 29 | dependencies = [ 30 | "streamlit >= 1.0" 31 | ] 32 | 33 | [project.optional-dependencies] 34 | devel = [ 35 | "wheel", 36 | "twine", 37 | "setuptools >= 42", 38 | ] 39 | 40 | [tool.setuptools_scm] 41 | #version_file = "streamlit_scroll_navigation/_version.py" 42 | local_scheme = "no-local-version" # This will strip the local version 43 | version_scheme = "guess-next-dev" 44 | 45 | 46 | [tool.setuptools] 47 | include-package-data = true 48 | 49 | [project.urls] 50 | "homepage" = "https://github.com/snpm/streamlit-scroll-navigation/" -------------------------------------------------------------------------------- /streamlit_scroll_navigation/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import streamlit.components.v1 as components 3 | from typing import * 4 | import requests 5 | import streamlit as st 6 | 7 | dev_url = "http://localhost:3000" 8 | parent_dir = os.path.dirname(os.path.abspath(__file__)) 9 | build_dir = os.path.join(parent_dir, "frontend/build") 10 | 11 | _RELEASE = True 12 | COMPONENT_NAME="scroll_navbar" 13 | if not _RELEASE: 14 | _component_func = components.declare_component( 15 | COMPONENT_NAME, 16 | url=dev_url, 17 | ) 18 | else: 19 | _component_func = components.declare_component(COMPONENT_NAME, path=build_dir) 20 | 21 | def inject_crossorigin_interface(): 22 | """Inject the CrossOriginInterface script into the parent scope.""" 23 | 24 | # Load text content of COI 25 | content = None 26 | if _RELEASE: 27 | interface_script_path = os.path.join(build_dir, "CrossOriginInterface.min.js") 28 | content = open(interface_script_path).read() 29 | else: 30 | # Load the script from dev_url 31 | response = requests.get(f"{dev_url}/CrossOriginInterface.js") 32 | content = response.text 33 | 34 | # Run COI content in parent 35 | # This works because streamlit.components.v1.html() creates an iframe from same domain as the parent scope 36 | # Same domain can bypass sandbox restrictions to create an interface for cross-origin iframes 37 | # This allows custom components to interact with parent scope 38 | components.html( 39 | f"""""", 49 | height=0, 50 | width=0, 51 | ) 52 | def instantiate_crossorigin_interface(key): 53 | """Instantiate the CrossOriginInterface in the parent scope that responds to messages for key.""" 54 | components.html( 55 | f"""""", 59 | height=0, 60 | width=0, 61 | ) 62 | 63 | class ForceAnchor: 64 | anchor:str 65 | def __init__(self): 66 | self.anchor = None 67 | 68 | def push(self, anchor): 69 | self.anchor = anchor 70 | 71 | def pop(self): 72 | anchor = self.anchor 73 | self.anchor = None 74 | return anchor 75 | 76 | @st.fragment() 77 | def scroll_navbar( 78 | anchor_ids: Collection[str], 79 | key: str = 'scroll_navbar_default', 80 | anchor_icons: Collection[str] = None, 81 | anchor_labels: Collection[str] = None, 82 | force_anchor: ForceAnchor = None, 83 | orientation: Literal['vertical', 'horizontal'] = 'vertical', 84 | override_styles: Dict[str, str] = {}, 85 | auto_update_anchor: bool = True, 86 | disable_scroll: bool = False, 87 | ) -> str: 88 | """ 89 | Creates a scroll navigation bar component. 90 | Parameters: 91 | anchor_ids (Collection[str]): A collection of anchor IDs that can be navigated to. 92 | key (str, optional): 93 | A unique key for this component. Any component beyond the first one should specify a key. 94 | Defaults to 'scroll_navbar_default'. 95 | anchor_icons (Collection[str], optional): 96 | A collection of icons for each navigation button. 97 | Each icon corresponds to an anchor in anchor_ids. 98 | Defaults to None. 99 | anchor_labels (Collection[str], optional): 100 | A collection of labels for each navigation button. 101 | Each label corresponds to an anchor in anchor_ids. 102 | If None, the anchor IDs will be used. Defaults to None. 103 | force_anchor (str, ForceAnchor): 104 | A ForceAnchor object to push anchors to programatically select. 105 | Setting this and pushing an anchor ID will simulate clicking on an anchor. Defaults to None. 106 | orientation (Literal['vertical', 'horizontal'], optional): 107 | The orientation of the navigation bar. Defaults to 'vertical'. 108 | override_styles (Dict[str, str], optional): 109 | A dictionary of styles to override default styles. Defaults to {}. 110 | auto_update_anchor (bool, optional): 111 | If True, the highlighted anchor will automatically update to the next nearest anchor when the current one is scrolled out of view. 112 | Defaults to True. 113 | disable_scroll (bool, optional): 114 | If True, navigation will snap instantly to anchors. 115 | Defaults to False. 116 | Returns: 117 | str: The ID of the anchor that is currently selected. 118 | Example: 119 | ```# Create a dummy streamlit page 120 | import streamlit as st 121 | anchor_ids = [f"anchor {num}" for num in range(10)]] 122 | for anchor in anchor_ids: 123 | st.subheader(anchor,anchor=anchor) 124 | st.write(["content "]*100) 125 | 126 | # Add a scroll navigation bar for anchors 127 | from screamlit_scroll_navigation import scroll_navbar 128 | with st.sidebar(): 129 | scroll_navbar(anchor_ids)``` 130 | """ 131 | 132 | inject_crossorigin_interface() 133 | instantiate_crossorigin_interface(key) 134 | # Pop the anchor string from ForceAnchor object 135 | force_anchor_str = force_anchor.pop() if force_anchor else None 136 | component_value = _component_func( 137 | anchor_ids=anchor_ids, 138 | key=key, 139 | anchor_icons=anchor_icons, 140 | anchor_labels=anchor_labels, 141 | force_anchor=force_anchor_str, 142 | orientation=orientation, 143 | override_styles=override_styles, 144 | auto_update_anchor=auto_update_anchor, 145 | disable_scroll=disable_scroll, 146 | ) 147 | return component_value -------------------------------------------------------------------------------- /streamlit_scroll_navigation/frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "semi": false, 4 | "trailingComma": "es5" 5 | } 6 | -------------------------------------------------------------------------------- /streamlit_scroll_navigation/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scroll_navigation", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "babel-preset-react-app": "^10.0.0", 7 | "bootstrap-icons": "^1.11.3", 8 | "react": "^16.13.1", 9 | "react-dom": "^16.13.1", 10 | "streamlit-component-lib": "^2.0.0" 11 | }, 12 | "scripts": { 13 | "terser-build": "terser ./public/CrossOriginInterface.js --compress --mangle -o ./build/CrossOriginInterface.min.js", 14 | "start": "react-scripts start", 15 | "build": "react-scripts build", 16 | "test": "react-scripts test", 17 | "eject": "react-scripts eject", 18 | "postbuild": "npm run terser-build" 19 | }, 20 | "eslintConfig": { 21 | "extends": "react-app" 22 | }, 23 | "browserslist": { 24 | "production": [ 25 | ">0.2%", 26 | "not dead", 27 | "not op_mini all" 28 | ], 29 | "development": [ 30 | "last 1 chrome version", 31 | "last 1 firefox version", 32 | "last 1 safari version" 33 | ] 34 | }, 35 | "homepage": ".", 36 | "devDependencies": { 37 | "@types/node": "^12.0.0", 38 | "@types/react": "^16.9.0", 39 | "@types/react-dom": "^16.9.0", 40 | "react-scripts": "^5.0.1", 41 | "terser": "^5.36.0", 42 | "terser-webpack-plugin": "^5.3.10", 43 | "typescript": "^4.2.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /streamlit_scroll_navigation/frontend/public/CrossOriginInterface.js: -------------------------------------------------------------------------------- 1 | //Source for CrossOriginInterface class. 2 | //Build with terser: 3 | // npx terser CrossOriginInterface.js --compress --mangle 'pure_funcs=["console.debug"]' --output ../build/CrossOriginInterface.min.js 4 | class CrossOriginInterface { 5 | static instances = {}; 6 | constructor(key) { 7 | if (CrossOriginInterface.instances[key]) { 8 | console.error('CrossOriginInterface instance already exists with key', key); 9 | return CrossOriginInterface.instances[key]; 10 | } 11 | CrossOriginInterface.instances[key] = this; 12 | this.sortedAnchors = []; 13 | this.trackedAnchors = new Set(); 14 | this.anchorVisibleStates = {}; 15 | this.activeAnchorId = null; 16 | this.component = null; 17 | this.autoUpdateAnchor = false; 18 | this.key = key; 19 | this.styles = null; 20 | this.disable_scroll = false; 21 | this.updateId = 0 22 | this.enroute = false; 23 | this.untrackedanchors = []; 24 | window.addEventListener("message", this.handleMessage.bind(this)); 25 | 26 | //Try to track untracked anchors every 200ms 27 | setInterval(() => { 28 | this.poll_untrackedanchors(); 29 | }, 200); 30 | } 31 | 32 | register(component, autoUpdateAnchor, emphasisStyle) { 33 | this.component = component; 34 | this.autoUpdateAnchor = autoUpdateAnchor; 35 | this.emphasisStyle = emphasisStyle; 36 | console.debug('Registered component for key ', this.key, ": ", component, autoUpdateAnchor); 37 | } 38 | 39 | //Styles from ScrollNavigationBar.tsx 40 | updateConfig(styles, disable_scroll) { 41 | this.styles = styles; 42 | this.disable_scroll = disable_scroll; 43 | console.debug('Updated config', styles, disable_scroll); 44 | } 45 | 46 | //Scroll to the anchor with the provided anchorId and call updateActiveAnchor 47 | scroll(anchorId) { 48 | const element = document.getElementById(anchorId); 49 | console.debug('Scrolling to', anchorId); 50 | if (element) { 51 | //Apply smooth or instant scrolling 52 | const behavior = this.disable_scroll ? 'instant' : 'smooth'; 53 | //If anchorId isn't on page yet, set enroute flag 54 | if (!this.anchorVisibleStates[anchorId]) { 55 | this.enroute = true; 56 | } 57 | this.updateActiveAnchor(anchorId); 58 | element.scrollIntoView({ behavior , block: 'start'}); 59 | } 60 | this.emphasize(anchorId); 61 | 62 | } 63 | 64 | //Emphasize the anchor by scaling it up and down 65 | emphasize(anchorId) { 66 | const element = document.getElementById(anchorId); 67 | if (element) { 68 | if (this.styles === null) { 69 | console.error('Styles have not been set'); 70 | return; 71 | } 72 | 73 | const emphasisStyle = this.styles["anchorEmphasis"] || null; 74 | if (emphasisStyle === null) { 75 | console.error('emphasisStyle has not been set'); 76 | return; 77 | } 78 | console.debug('Emphasizing', anchorId, emphasisStyle); 79 | 80 | //Apply each key in styles to the element 81 | for (const key in emphasisStyle) { 82 | element.style[key] = emphasisStyle[key]; 83 | } 84 | console.debug('Emphasis applied', anchorId, emphasisStyle); 85 | 86 | // Remove the effect after the animation completes 87 | setTimeout(() => { 88 | //Reset scale 89 | //We need to keep element.transition to have animation 90 | element.style.transform = 'scale(1)'; 91 | console.debug('Emphasis removed', anchorId); 92 | }, 600); 93 | } 94 | else { 95 | console.debug('Element does not exist for emphasis', anchorId); 96 | } 97 | } 98 | 99 | //Update the active anchor to the provided anchorId 100 | updateActiveAnchor(anchorId) { 101 | if (this.trackedAnchors.has(anchorId)) { 102 | this.activeAnchorId = anchorId; 103 | console.debug('Updated active anchor', anchorId); 104 | } 105 | else { 106 | console.error('Anchor is not being tracked', anchorId ?? 'null'); 107 | } 108 | } 109 | 110 | //Check if the current active anchor is still visible, if not find the closest visible anchor to make active 111 | checkBestAnchor(){ 112 | //If enroute, don't change active anchor 113 | if (this.enroute) { 114 | return; 115 | } 116 | 117 | if (this.activeAnchorId) { 118 | //Check if active anchor is visible, if not we need a new active anchor 119 | if (this.anchorVisibleStates[this.activeAnchorId]) { 120 | return; 121 | } 122 | 123 | //Search sorted anchors closest to the current active anchor for first visible 124 | let newActiveAnchorId = null; 125 | const activeAnchorIndex = this.sortedAnchors.indexOf(this.activeAnchorId); 126 | // If anchor dissapeared above screen, find the next anchor below that is visible. 127 | for (let i = activeAnchorIndex + 1; i < this.sortedAnchors.length; i++) { 128 | const anchorId = this.sortedAnchors[i]; 129 | if (this.anchorVisibleStates[anchorId]) { 130 | newActiveAnchorId = anchorId; 131 | break; 132 | } 133 | } 134 | if (newActiveAnchorId === null) { 135 | // If anchor dissapeared below screen, find the next anchor above that is visible. 136 | for (let i = activeAnchorIndex - 1; i >= 0; i--) { 137 | const anchorId = this.sortedAnchors[i]; 138 | if (this.anchorVisibleStates[anchorId]) { 139 | newActiveAnchorId = anchorId; 140 | break; 141 | } 142 | } 143 | } 144 | 145 | //If new anchor found, update the component's active anchor 146 | if (newActiveAnchorId !== null) { 147 | this.activeAnchorId = newActiveAnchorId; 148 | this.postUpdateActiveAnchor(this.activeAnchorId); 149 | } 150 | } 151 | } 152 | 153 | postUpdateActiveAnchor(anchor_id) { 154 | this.postMessage( 155 | 'updateActiveAnchor', 156 | {anchor_id, update_id: this.updateId++} 157 | ); 158 | } 159 | 160 | //Send a message to the component 161 | postMessage(COMPONENT_method, data = { anchor_id = null, update_id = null} = {}) { 162 | if (this.component === null) { 163 | console.error('Component has not been registered'); 164 | return; 165 | } 166 | this.component.postMessage({ COMPONENT_method: COMPONENT_method, key: this.key, ...data}, '*'); 167 | } 168 | 169 | observer = new IntersectionObserver((entries) => { 170 | entries.forEach(entry => { 171 | const anchorId = entry.target.id; 172 | if (entry.isIntersecting) { 173 | this.anchorVisibleStates[anchorId] = true; 174 | if (this.activeAnchorId === anchorId) { 175 | this.enroute = false; 176 | } 177 | } else { 178 | this.anchorVisibleStates[anchorId] = false; 179 | // Rerun checkBestAnchor if the active anchor is no longer visible 180 | if (this.activeAnchorId === anchorId) { 181 | //run checkBestAnchor after 0ms to ensure anchors update 182 | setTimeout(() => { 183 | this.checkBestAnchor(); 184 | },0); 185 | 186 | } 187 | } 188 | }); 189 | }, { threshold: [0,1] }); 190 | 191 | //Start tracking anchors for visibility 192 | trackAnchors(anchor_ids) { 193 | for (const anchorId of anchor_ids) { 194 | if (this.trackedAnchors.has(anchorId)) { 195 | console.debug('Anchor is already being tracked', anchorId); 196 | continue; 197 | } 198 | 199 | const anchor = document.getElementById(anchorId); 200 | if (!anchor) { 201 | console.warn('Anchor does not exist in document: ', anchorId, ". Queueing for later."); 202 | this.untrackedanchors.push(anchorId); 203 | continue 204 | } 205 | this.trackedAnchors.add(anchorId); 206 | 207 | //If no active anchor, set this anchor as active 208 | if (this.activeAnchorId === null) { 209 | this.activeAnchorId = anchorId; 210 | } 211 | 212 | //Insert anchor into sortedAnchors based on its position in the document 213 | let inserted = false; 214 | for (let i = 0; i < this.sortedAnchors.length; i++) { 215 | const currentAnchor = document.getElementById(this.sortedAnchors[i]); 216 | if (anchor.compareDocumentPosition(currentAnchor) & Node.DOCUMENT_POSITION_FOLLOWING) { 217 | this.sortedAnchors.splice(i, 0, anchorId); 218 | inserted = true; 219 | break; 220 | } 221 | } 222 | if (!inserted) { 223 | this.sortedAnchors.push(anchorId); 224 | } 225 | 226 | this.observer.observe(anchor); 227 | console.debug('Started tracking anchor', anchorId); 228 | } 229 | } 230 | poll_untrackedanchors() { 231 | //If there are untracked anchors, try to track them 232 | if (this.untrackedanchors.length > 0) { 233 | const untrackedanchors = this.untrackedanchors; 234 | this.untrackedanchors = []; 235 | 236 | this.trackAnchors(untrackedanchors); 237 | console.log("ASDFASDF") 238 | } 239 | } 240 | //Handle messages from the component 241 | handleMessage(event) { 242 | const { COI_method, key} = event.data; 243 | 244 | //Check if message is intended for COI 245 | if (!COI_method || !key) { 246 | return; 247 | } 248 | 249 | //Check if message is intended for this COI instance 250 | if (key !== this.key) { 251 | return; 252 | } 253 | console.debug("COI with key", key, "received message", event.data); 254 | 255 | //If component is not registered, only allow registration method 256 | if (this.component === null) { 257 | if (COI_method === 'register') { 258 | const {auto_update_anchor, emphasis_style} = event.data; 259 | this.register(event.source, auto_update_anchor, emphasis_style); 260 | } 261 | else { 262 | console.error('Must register component with this CrossOriginInterface before calling other methods', event.data); 263 | } 264 | } 265 | 266 | switch (COI_method) { 267 | case 'register': 268 | console.debug('Register can only be called once per key.'); 269 | break; 270 | case 'updateConfig': 271 | const {styles, disable_scroll} = event.data; 272 | this.updateConfig(styles, disable_scroll); 273 | break; 274 | case 'scroll': 275 | const { anchor_id: scrollAnchorId } = event.data; 276 | this.scroll(scrollAnchorId); 277 | break; 278 | case 'trackAnchors': 279 | const { anchor_ids } = event.data; 280 | this.trackAnchors(anchor_ids); 281 | break; 282 | case 'updateActiveAnchor': 283 | const { anchor_id: updateAnchorId } = event.data; 284 | this.updateActiveAnchor(updateAnchorId); 285 | break; 286 | default: 287 | console.error('Unknown method', COI_method); 288 | } 289 | } 290 | } 291 | function instantiateCrossOriginInterface(key) { 292 | return new CrossOriginInterface(key); 293 | } -------------------------------------------------------------------------------- /streamlit_scroll_navigation/frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |