├── .editorconfig ├── .eslintrc.cjs ├── .github ├── CODE_OF_CONDUCT.md ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── ci.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierignore ├── .prettierrc.cjs ├── .storybook ├── main.ts ├── preview.ts └── storybook.css ├── .swcrc ├── .yarn └── releases │ └── yarn-3.5.0.cjs ├── .yarnrc.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── assets ├── custom-highlight-background.png ├── logo-inkscape-src.svg └── logo.svg ├── package.json ├── rollup.config.js ├── src ├── Skeleton.tsx ├── SkeletonStyleProps.ts ├── SkeletonTheme.tsx ├── SkeletonThemeContext.ts ├── __stories__ │ ├── Post.stories.tsx │ ├── Skeleton.stories.tsx │ ├── SkeletonTheme.stories.tsx │ ├── components │ │ ├── Box.tsx │ │ ├── Post.tsx │ │ ├── SideBySide.tsx │ │ └── index.ts │ └── styles │ │ └── Skeleton.stories.css ├── __tests__ │ ├── Skeleton.test.tsx │ ├── SkeletonTheme.test.tsx │ ├── __helpers__ │ │ └── index.ts │ └── index.test.tsx ├── index.ts └── skeleton.css ├── tsconfig.json ├── vite.config.ts └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parserOptions: { 3 | project: './tsconfig.json', 4 | }, 5 | extends: [ 6 | 'airbnb', 7 | 'airbnb-typescript', 8 | 'airbnb/hooks', 9 | 'plugin:testing-library/react', 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 12 | 'prettier', 13 | ], 14 | plugins: ['@typescript-eslint', 'testing-library'], 15 | ignorePatterns: ['*.js', '*.cjs', 'dist/', 'vite.config.ts'], 16 | settings: { 17 | 'testing-library/custom-renders': 'off', 18 | }, 19 | rules: { 20 | 'no-plusplus': 'off', 21 | 'no-restricted-syntax': [ 22 | 'error', 23 | // Options from https://github.com/airbnb/javascript/blob/master/packages/eslint-config-airbnb-base/rules/style.js 24 | // with for-of removed 25 | { 26 | selector: 'ForInStatement', 27 | message: 28 | 'for..in loops iterate over the entire prototype chain, which is virtually never what you want. Use Object.{keys,values,entries}, and iterate over the resulting array.', 29 | }, 30 | { 31 | selector: 'LabeledStatement', 32 | message: 33 | 'Labels are a form of GOTO; using them makes code confusing and hard to maintain and understand.', 34 | }, 35 | { 36 | selector: 'WithStatement', 37 | message: 38 | '`with` is disallowed in strict mode because it makes code impossible to predict and optimize.', 39 | }, 40 | ], 41 | 42 | 'import/extensions': 'off', 43 | 'import/no-extraneous-dependencies': [ 44 | 'error', 45 | { 46 | devDependencies: ['src/__stories__/**/*', 'src/__tests__/**/*'], 47 | }, 48 | ], 49 | 'import/prefer-default-export': 'off', 50 | 51 | 'react/require-default-props': 'off', 52 | 'react/function-component-definition': 'off', 53 | 54 | 'testing-library/no-node-access': 'off', 55 | }, 56 | }; 57 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | srmagura@gmail.com. All complaints will be reviewed and investigated promptly 64 | and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][mozilla coc]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][faq]. Translations are available 126 | at [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [mozilla coc]: https://github.com/mozilla/diversity 131 | [faq]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior. 14 | 15 | **Actual Behavior** 16 | What actually happened. 17 | 18 | **Expected Behavior** 19 | A clear and concise description of what you expected to happen. 20 | 21 | **Versions** 22 | 23 | - react-loading-skeleton version: 24 | - Browser version: 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: Checkout repository 8 | uses: actions/checkout@v2 9 | - name: Set up Node 10 | uses: actions/setup-node@v2 11 | with: 12 | node-version: '16' 13 | cache: 'yarn' 14 | - name: Install 15 | run: yarn install --immutable 16 | 17 | # `yarn build` does not type-check the tests 18 | - name: tsc 19 | run: yarn tsc 20 | 21 | - name: Build 22 | run: yarn build 23 | - name: Test 24 | run: yarn test 25 | - name: Lint 26 | run: yarn lint 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | 4 | /.yarn/* 5 | !/.yarn/patches 6 | !/.yarn/plugins 7 | !/.yarn/releases 8 | !/.yarn/sdks 9 | /.pnp.* 10 | 11 | *.orig -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint-staged 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | }; 4 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from '@storybook/react-vite'; 2 | 3 | const config: StorybookConfig = { 4 | stories: ['../src/**/*.stories.@(ts|tsx)'], 5 | addons: [ 6 | '@storybook/addon-links', 7 | '@storybook/addon-essentials', 8 | '@storybook/addon-interactions', 9 | ], 10 | framework: { 11 | name: '@storybook/react-vite', 12 | options: {}, 13 | }, 14 | }; 15 | 16 | export default config; 17 | -------------------------------------------------------------------------------- /.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import './storybook.css'; 2 | import '../src/skeleton.css'; 3 | 4 | import type { Preview } from '@storybook/react'; 5 | 6 | const preview: Preview = { 7 | parameters: { 8 | actions: { argTypesRegex: '^on[A-Z].*' }, 9 | controls: { 10 | matchers: { 11 | color: /(background|color)$/i, 12 | date: /Date$/, 13 | }, 14 | }, 15 | }, 16 | }; 17 | 18 | export default preview; 19 | -------------------------------------------------------------------------------- /.storybook/storybook.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: sans-serif; 3 | } 4 | -------------------------------------------------------------------------------- /.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "jsc": { 3 | "parser": { 4 | "syntax": "typescript", 5 | "tsx": true, 6 | "dynamicImport": true 7 | }, 8 | "target": "es2021" 9 | }, 10 | "module": { 11 | "type": "commonjs" 12 | }, 13 | "sourceMaps": true 14 | } 15 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | yarnPath: .yarn/releases/yarn-3.5.0.cjs 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 3.5.0 2 | 3 | ### Features 4 | 5 | - Add optional `customHighlightBackground` prop. (#233) 6 | 7 | ## 3.4.0 8 | 9 | ### Features 10 | 11 | - Remove `z-index: 1` from the skeleton. This was a Safari-specific workaround that is no longer necessary in the latest versions of the browser. (#216) 12 | 13 | ## 3.3.1 14 | 15 | ### Bug Fixes 16 | 17 | - Fix `main` and `module` being incorrect in `package.json`. (#191) 18 | 19 | ## 3.3.0 20 | 21 | ### Features 22 | 23 | - The library is now compatible with TypeScript's `"moduleResolution": "nodenext"` compiler option. (#187) 24 | 25 | ## 3.2.1 26 | 27 | ### Bug Fixes 28 | 29 | - The skeleton now has `user-select: none` so that it cannot be selected. (#179) 30 | 31 | ### Thanks! 32 | 33 | - @larsmunkholm 34 | 35 | ## 3.2.0 36 | 37 | ### Features 38 | 39 | - The skeleton animation no longer plays for users who have enabled the `prefers-reduced-motion` accessibility setting. 40 | 41 | ### Thanks! 42 | 43 | - @RoseMagura 44 | 45 | ## 3.1.1 46 | 47 | ### Chores 48 | 49 | - Add the `'use client'` directive to make the library compatible with React Server Components and Next.js 13. (#162) 50 | 51 | ### Thanks! 52 | 53 | - @cravend 54 | 55 | ## 3.1.0 56 | 57 | ### Features 58 | 59 | - If `count` is set to a decimal number like 3.5, the component will display 3 full-width skeletons followed by 1 half-width skeleton. (#136) 60 | 61 | ## 3.0.3 62 | 63 | ### Bug Fixes 64 | 65 | - Fix an edge case where the animated highlight had the wrong vertical position (#133) 66 | 67 | ### Thanks! 68 | 69 | - @HexM7 70 | 71 | ## 3.0.2 72 | 73 | ### Bug Fixes 74 | 75 | - Fix explicitly setting a `Skeleton` prop to undefined, like ``, blocking style options from the `SkeletonTheme` 76 | (#128) 77 | - If you were relying on this behavior to block values from the `SkeletonTheme`, you can render a nested `SkeletonTheme` to override a theme defined higher up in the component tree, OR explicitly set one or more `Skeleton` props back to their default values e.g. `` 78 | 79 | ## 3.0.1 80 | 81 | ### Bug Fixes 82 | 83 | - Fix circle skeleton animation being broken in Safari (#120) 84 | - Fix `SkeletonProps` not being exported from the main entry point (#118) 85 | - Fix `enableAnimation` prop having no effect. This was a regression. 86 | 87 | ## 3.0.0 88 | 89 | ### Migration Guide 90 | 91 | 1. Add the new required CSS import: 92 | 93 | ```js 94 | import 'react-loading-skeleton/dist/skeleton.css'; 95 | ``` 96 | 97 | 2. Read the full list of breaking changes to see if any affect you. 98 | 99 | ### Breaking Changes 100 | 101 | - Drop Emotion dependency, add CSS file that must be imported 102 | - Dropping Emotion avoids conflicts when multiple Emotion versions are used on one page and reduces bundle size 103 | - Reimplement `SkeletonTheme` using React context 104 | - The old `SkeletonTheme` rendered a `
` which was undesirable in many cases. The new `SkeletonTheme` does not render any DOM elements. 105 | - The old `SkeletonTheme` did not work if the `Skeleton` was rendered in a portal. The new `SkeletonTheme` does work in this case. 106 | - `SkeletonTheme`: rename the `color` prop to `baseColor` 107 | - Convert to TypeScript 108 | - Publish code as ES2018 to reduce bundle size 109 | - Require React >= 16.8.0 110 | - Drop Internet Explorer support 111 | 112 | If you need to support Internet Explorer or use an old version of React, please continue to use `react-loading-skeleton` v2. 113 | 114 | ### Features 115 | 116 | - Add many new style-related props to `SkeletonTheme` 117 | - Publish an ES module in addition to a CommonJS module 118 | - Add `direction` prop to support right-to-left animation 119 | - Add `enableAnimation` prop to allow disabling the animation 120 | - Add `containerClassName` prop to allow customizing the container element 121 | - Add `containerTestId` to make testing easier 122 | - Add `aria-live` and `aria-busy` attributes to the skeleton container to 123 | improve screen reader support 124 | 125 | ### Other Changes 126 | 127 | - Optimize animation performance: 128 | - The old animation animated the `background-position` property which made the browser repaint the gradient on every frame. 129 | - The new animation animates the `transform` of a pseudoelement. This avoids repaints and results in an observable decrease in CPU usage. 130 | - No longer require `width` and `height` to be set for the `circle` prop to work 131 | - Change the default `duration` from 1.2 s to 1.5 s 132 | - Make the default `Skeleton` base color a _tiny_ bit darker so that the animation is more visible 133 | 134 | ### Bug Fixes 135 | 136 | - Several common issues are now resolved as a result of removing Emotion 137 | - Fix multi-line skeletons not working with the `width` prop 138 | - Fix the type of the `wrapper` prop in the type definitions 139 | 140 | ### Thanks! 141 | 142 | - @srmagura 143 | - @aboodz 144 | - @RoseMagura 145 | - @saadaouad 146 | - @rlaunch 147 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to react-loading-skeleton 2 | 3 | Contributions are welcome. 4 | 5 | ## Development 6 | 7 | 1. `yarn install` 8 | 2. `yarn setup` — setup precommit hook 9 | 3. Check for type errors: `yarn tsc` 10 | 4. Run Rollup: `yarn build` 11 | 5. Run tests: `yarn test` 12 | 6. Run ESLint: `yarn lint` 13 | 7. Run Storybook: `yarn storybook` 14 | 15 | ## Publishing 16 | 17 | 1. Make sure that the CI workflow succeeded. 18 | 2. Increment version in `package.json`. 19 | 3. (If production release) Add a git tag in the format `v1.0.0`. 20 | 4. Commit and push. Remember to push tags as well with `git push --tags`. 21 | 5. `yarn npm publish` or `yarn npm publish --tag next`. The `prepack` script will automatically do a clean and build. 22 | 6. (If production release) Create a new release in GitHub. 23 | 24 | ## Test Projects Repository 25 | 26 | There are some test projects that use `react-loading-skeleton` in the 27 | [react-loading-skeleton-test-projects](https://github.com/srmagura/react-loading-skeleton-test-projects) 28 | repository. 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 David Tang 2 | 3 | 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: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | 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. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | Logo 4 | 5 |

React Loading Skeleton

6 |

7 | Make beautiful, animated loading skeletons that automatically adapt to your app. 8 |

9 |

10 | 11 | Open on CodeSandbox 12 |

13 | Gif of the skeleton in action 14 |
15 | 16 | Learn about the [changes in version 17 | 3](https://github.com/dvtng/react-loading-skeleton/releases/tag/v3.0.0), or view 18 | the [v2 19 | documentation](https://github.com/dvtng/react-loading-skeleton/tree/v2#readme). 20 | 21 | ## Basic Usage 22 | 23 | Install via one of: 24 | 25 | ```bash 26 | yarn add react-loading-skeleton 27 | npm install react-loading-skeleton 28 | ``` 29 | 30 | ```tsx 31 | import Skeleton from 'react-loading-skeleton' 32 | import 'react-loading-skeleton/dist/skeleton.css' 33 | 34 | // Simple, single-line loading skeleton 35 | // Five-line loading skeleton 36 | ``` 37 | 38 | ## Principles 39 | 40 | ### Adapts to the styles you have defined 41 | 42 | The `Skeleton` component should be used directly in your components in place of 43 | content that is loading. While other libraries require you to meticulously craft 44 | a skeleton screen that matches the font size, line height, and margins of your 45 | content, the `Skeleton` component is automatically sized to the correct 46 | dimensions. 47 | 48 | For example: 49 | 50 | ```tsx 51 | function BlogPost(props) { 52 | return ( 53 |
54 |

{props.title || }

55 | {props.body || } 56 |
57 | ); 58 | } 59 | ``` 60 | 61 | ...will produce correctly-sized skeletons for the heading and body without any 62 | further configuration. 63 | 64 | This ensures the loading state remains up-to-date with any changes 65 | to your layout or typography. 66 | 67 | ### Don't make dedicated skeleton screens 68 | 69 | Instead, make components with _built-in_ skeleton states. 70 | 71 | This approach is beneficial because: 72 | 73 | 1. It keeps styles in sync. 74 | 2. Components should represent all possible states — loading included. 75 | 3. It allows for more flexible loading patterns. In the blog post example above, 76 | it's possible to have the title load before the body, while having both 77 | pieces of content show loading skeletons at the right time. 78 | 79 | ## Theming 80 | 81 | Customize individual skeletons with props, or render a `SkeletonTheme` to style 82 | all skeletons below it in the React hierarchy: 83 | 84 | ```tsx 85 | import Skeleton, { SkeletonTheme } from 'react-loading-skeleton'; 86 | 87 | return ( 88 | 89 |

90 | 91 |

92 |
93 | ); 94 | ``` 95 | 96 | ## Props Reference 97 | 98 | ### `Skeleton` only 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 117 | 118 | 119 | 120 | 121 | 125 | 126 | 127 | 128 | 129 | 133 | 134 | 135 | 136 | 137 | 141 | 142 | 143 | 144 | 145 | 149 | 150 | 151 | 152 | 153 | 158 | 159 | 160 | 161 | 162 | 167 | 168 | 169 | 170 |
PropDescriptionDefault
count?: number 112 | The number of lines of skeletons to render. If 113 | count is a decimal number like 3.5, 114 | three full skeletons and one half-width skeleton will be 115 | rendered. 116 | 1
wrapper?: React.FunctionComponent
<PropsWithChildren<unknown>>
122 | A custom wrapper component that goes around the individual skeleton 123 | elements. 124 |
circle?: boolean 130 | Makes the skeleton circular by setting border-radius to 131 | 50%. 132 | false
className?: string 138 | A custom class name for the individual skeleton elements which is used 139 | alongside the default class, react-loading-skeleton. 140 |
containerClassName?: string 146 | A custom class name for the <span> that wraps the 147 | individual skeleton elements. 148 |
containerTestId?: string 154 | A string that is added to the container element as a 155 | data-testid attribute. Use it with 156 | screen.getByTestId('...') from React Testing Library. 157 |
style?: React.CSSProperties 163 | This is an escape hatch for advanced use cases and is not the preferred 164 | way to style the skeleton. Props (e.g. width, 165 | borderRadius) take priority over this style object. 166 |
171 | 172 | ### `Skeleton` and `SkeletonTheme` 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 227 | 228 | 229 | 230 | 231 | 236 | 237 | 238 | 239 | 240 | 243 | 244 | 245 | 246 |
PropDescriptionDefault
baseColor?: stringThe background color of the skeleton.#ebebeb
highlightColor?: stringThe highlight color in the skeleton animation.#f5f5f5
width?: string | numberThe width of the skeleton.100%
height?: string | numberThe height of each skeleton line.The font size
borderRadius?: string | numberThe border radius of the skeleton.0.25rem
inline?: boolean 211 | By default, a <br /> is inserted after each skeleton so 212 | that each skeleton gets its own line. When inline is true, no 213 | line breaks are inserted. 214 | false
duration?: numberThe length of the animation in seconds.1.5
direction?: 'ltr' | 'rtl' 225 | The direction of the animation, either left-to-right or right-to-left. 226 | 'ltr'
enableAnimation?: boolean 232 | Whether the animation should play. The skeleton will be a solid color when 233 | this is false. You could use this prop to stop the animation 234 | if an error occurs. 235 | true
customHighlightBackground?: string 241 | Allows you to override the background-image property of the highlight element, enabling you to fully customize the gradient. See example below. 242 | undefined
247 | 248 | ## Examples 249 | 250 | ### Custom Wrapper 251 | 252 | There are two ways to wrap a skeleton in a container: 253 | 254 | ```tsx 255 | function Box({ children }: PropsWithChildren) { 256 | return ( 257 |
267 | {children} 268 |
269 | ); 270 | } 271 | 272 | // Method 1: Use the wrapper prop 273 | const wrapped1 = ; 274 | 275 | // Method 2: Do it "the normal way" 276 | const wrapped2 = ( 277 | 278 | 279 | 280 | ); 281 | ``` 282 | 283 | ### Custom Highlight Background 284 | 285 | You may want to make the gradient used in the highlight element narrower or wider. To do this, you can set the `customHighlightBackground` prop. Here's an example of a narrow highlight: 286 | 287 | ```tsx 288 | 289 | ``` 290 | 291 | **If you use this prop, the `baseColor` and `highlightColor` props are ignored,** but you can still reference their corresponding CSS variables as shown in the above example. 292 | 293 | ![Custom highlight background example](assets/custom-highlight-background.png) 294 | 295 | ## Troubleshooting 296 | 297 | ### The skeleton width is 0 when the parent has `display: flex`! 298 | 299 | In the example below, the width of the skeleton will be 0: 300 | 301 | ```tsx 302 |
303 | 304 |
305 | ``` 306 | 307 | This happens because the skeleton has no intrinsic width. You can fix it by 308 | applying `flex: 1` to the skeleton container via the `containerClassName` prop. 309 | 310 | For example, if you are using Tailwind, your code would look like this: 311 | 312 | ```tsx 313 |
314 | 315 |
316 | ``` 317 | 318 | ### The height of my container is off by a few pixels! 319 | 320 | In the example below, the height of the `
` will be slightly larger than 30 321 | even though the `react-loading-skeleton` element is exactly 30px. 322 | 323 | ```tsx 324 |
325 | 326 |
327 | ``` 328 | 329 | This is a consequence of how `line-height` works in CSS. If you need the `
` 330 | to be exactly 30px tall, set its `line-height` to 1. [See 331 | here](https://github.com/dvtng/react-loading-skeleton/issues/23#issuecomment-939231878) 332 | for more details. 333 | 334 | ## Contributing 335 | 336 | Contributions are welcome! See `CONTRIBUTING.md` to get started. 337 | 338 | ## Acknowledgements 339 | 340 | Our logo is based off an image from [Font 341 | Awesome](https://fontawesome.com/license/free). Thanks! 342 | -------------------------------------------------------------------------------- /assets/custom-highlight-background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvtng/react-loading-skeleton/f8b040dade9cfaad7e3e6fbc50243d79f508f1ca/assets/custom-highlight-background.png -------------------------------------------------------------------------------- /assets/logo-inkscape-src.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 40 | 42 | 45 | 49 | 53 | 54 | 63 | 64 | 69 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 13 | 15 | 17 | 21 | 25 | 26 | 34 | 35 | 38 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-loading-skeleton", 3 | "version": "3.5.0", 4 | "description": "Make beautiful, animated loading skeletons that automatically adapt to your app.", 5 | "keywords": [ 6 | "react", 7 | "loading", 8 | "skeleton", 9 | "progress", 10 | "spinner" 11 | ], 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/dvtng/react-loading-skeleton.git" 15 | }, 16 | "license": "MIT", 17 | "author": "David Tang", 18 | "sideEffects": [ 19 | "**/*.css" 20 | ], 21 | "type": "module", 22 | "exports": { 23 | ".": { 24 | "types": "./dist/index.d.ts", 25 | "require": "./dist/index.cjs", 26 | "import": "./dist/index.js" 27 | }, 28 | "./dist/skeleton.css": "./dist/skeleton.css" 29 | }, 30 | "main": "dist/index.cjs", 31 | "module": "dist/index.js", 32 | "types": "dist/index.d.ts", 33 | "files": [ 34 | "dist/" 35 | ], 36 | "scripts": { 37 | "build": "yarn clean && tsc && rollup -c", 38 | "clean": "rimraf dist", 39 | "lint": "eslint .", 40 | "lint-staged": "lint-staged --no-stash", 41 | "prepack": "yarn run build", 42 | "prettier-all": "prettier . --write", 43 | "setup": "husky install", 44 | "storybook": "storybook dev -p 8080", 45 | "test": "vitest" 46 | }, 47 | "lint-staged": { 48 | "src/**/*.ts?(x)": [ 49 | "eslint --max-warnings 0 --fix", 50 | "prettier --write" 51 | ], 52 | "*.{md,js,cjs,yml,json}": "prettier --write", 53 | "vite.config.ts": "prettier --write", 54 | ".storybook/*.ts": "prettier --write" 55 | }, 56 | "devDependencies": { 57 | "@rollup/plugin-typescript": "^11.1.0", 58 | "@storybook/addon-essentials": "^7.0.7", 59 | "@storybook/addon-interactions": "^7.0.7", 60 | "@storybook/addon-links": "^7.0.7", 61 | "@storybook/blocks": "^7.0.7", 62 | "@storybook/react": "^7.0.7", 63 | "@storybook/react-vite": "^7.0.7", 64 | "@swc/core": "^1.3.56", 65 | "@testing-library/jest-dom": "^5.16.5", 66 | "@testing-library/react": "^12.1.5", 67 | "@types/react": "^18.2.0", 68 | "@types/react-dom": "^18.2.1", 69 | "@types/testing-library__jest-dom": "^5.14.5", 70 | "@typescript-eslint/eslint-plugin": "^5.59.1", 71 | "@typescript-eslint/parser": "^5.59.1", 72 | "@vitejs/plugin-react-swc": "^3.3.0", 73 | "eslint": "^8.39.0", 74 | "eslint-config-airbnb": "^19.0.4", 75 | "eslint-config-airbnb-typescript": "^17.0.0", 76 | "eslint-config-prettier": "^8.8.0", 77 | "eslint-plugin-import": "^2.27.5", 78 | "eslint-plugin-jsx-a11y": "^6.7.1", 79 | "eslint-plugin-react": "^7.32.2", 80 | "eslint-plugin-react-hooks": "^4.6.0", 81 | "eslint-plugin-testing-library": "^5.10.3", 82 | "husky": "^8.0.3", 83 | "jsdom": "^21.1.1", 84 | "lint-staged": "^13.2.2", 85 | "prettier": "^2.8.8", 86 | "prettier-plugin-packagejson": "^2.4.3", 87 | "prop-types": "^15.8.1", 88 | "react": "^17.0.2", 89 | "react-dom": "^17.0.2", 90 | "rimraf": "^5.0.0", 91 | "rollup": "^3.21.1", 92 | "rollup-plugin-copy": "^3.4.0", 93 | "storybook": "^7.0.7", 94 | "tslib": "^2.5.0", 95 | "typescript": "^5.0.4", 96 | "vite": "^4.3.3", 97 | "vite-plugin-checker": "^0.5.6", 98 | "vitest": "^0.30.1" 99 | }, 100 | "peerDependencies": { 101 | "react": ">=16.8.0" 102 | }, 103 | "packageManager": "yarn@3.5.0" 104 | } 105 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript'; 2 | import copy from 'rollup-plugin-copy'; 3 | 4 | export default { 5 | input: 'src/index.ts', 6 | external: ['react'], 7 | output: [ 8 | { 9 | dir: 'dist', 10 | format: 'es', 11 | 12 | // This disables the warning "Mixing named and default exports" 13 | exports: 'named', 14 | 15 | /** 16 | * This is required to prevent the error: 17 | * 18 | * Can't import the named export 'useContext' from non EcmaScript 19 | * module (only default export is available) 20 | * 21 | * This error occurred in a Webpack 4 app (Create React App). We can 22 | * hopefully remove the `interop` key if React decides to publish an 23 | * ES module and/or Webpack 4 usage declines. 24 | * 25 | * ----- 26 | * 27 | * Here is the Rollup documentation on `defaultOnly`: 28 | * 29 | * Named imports are forbidden. If such an import is encountered, 30 | * Rollup throws an error even in es and system formats. That way it 31 | * is ensures that the es version of the code is able to import 32 | * non-builtin CommonJS modules in Node correctly. 33 | */ 34 | interop: 'defaultOnly', 35 | /** 36 | * This is required to prevent the error: 37 | * 38 | * TypeError: createContext only works in Client Components. 39 | * Add the "use client" directive at the top of the file to use it. 40 | * 41 | * ----- 42 | * 43 | * Here is the Rollup documentation on `banner`: 44 | * A string to prepend/append to the bundle. You can also 45 | * supply a function that returns a Promise that resolves 46 | * to a string to generate it asynchronously 47 | * 48 | * (Note: banner and footer options will not 49 | * break sourcemaps). 50 | */ 51 | banner: "'use client';", 52 | }, 53 | { 54 | file: 'dist/index.cjs', 55 | format: 'cjs', 56 | exports: 'named', 57 | banner: "'use client';", 58 | }, 59 | ], 60 | plugins: [ 61 | typescript({ exclude: ['**/__tests__/**/*', '**/__stories__/**/*'] }), 62 | copy({ 63 | targets: [{ src: 'src/skeleton.css', dest: 'dist' }], 64 | }), 65 | ], 66 | }; 67 | -------------------------------------------------------------------------------- /src/Skeleton.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-array-index-key */ 2 | import React, { CSSProperties, PropsWithChildren, ReactElement } from 'react'; 3 | import { SkeletonThemeContext } from './SkeletonThemeContext.js'; 4 | import { SkeletonStyleProps } from './SkeletonStyleProps.js'; 5 | 6 | const defaultEnableAnimation = true; 7 | 8 | // For performance & cleanliness, don't add any inline styles unless we have to 9 | function styleOptionsToCssProperties({ 10 | baseColor, 11 | highlightColor, 12 | 13 | width, 14 | height, 15 | borderRadius, 16 | circle, 17 | 18 | direction, 19 | duration, 20 | enableAnimation = defaultEnableAnimation, 21 | 22 | customHighlightBackground, 23 | }: SkeletonStyleProps & { circle: boolean }): CSSProperties & 24 | Record<`--${string}`, string> { 25 | const style: ReturnType = {}; 26 | 27 | if (direction === 'rtl') style['--animation-direction'] = 'reverse'; 28 | if (typeof duration === 'number') 29 | style['--animation-duration'] = `${duration}s`; 30 | if (!enableAnimation) style['--pseudo-element-display'] = 'none'; 31 | 32 | if (typeof width === 'string' || typeof width === 'number') 33 | style.width = width; 34 | if (typeof height === 'string' || typeof height === 'number') 35 | style.height = height; 36 | 37 | if (typeof borderRadius === 'string' || typeof borderRadius === 'number') 38 | style.borderRadius = borderRadius; 39 | 40 | if (circle) style.borderRadius = '50%'; 41 | 42 | if (typeof baseColor !== 'undefined') style['--base-color'] = baseColor; 43 | if (typeof highlightColor !== 'undefined') 44 | style['--highlight-color'] = highlightColor; 45 | 46 | if (typeof customHighlightBackground === 'string') 47 | style['--custom-highlight-background'] = customHighlightBackground; 48 | 49 | return style; 50 | } 51 | 52 | export interface SkeletonProps extends SkeletonStyleProps { 53 | count?: number; 54 | wrapper?: React.FunctionComponent>; 55 | 56 | className?: string; 57 | containerClassName?: string; 58 | containerTestId?: string; 59 | 60 | circle?: boolean; 61 | style?: CSSProperties; 62 | } 63 | 64 | export function Skeleton({ 65 | count = 1, 66 | wrapper: Wrapper, 67 | 68 | className: customClassName, 69 | containerClassName, 70 | containerTestId, 71 | 72 | circle = false, 73 | 74 | style: styleProp, 75 | ...originalPropsStyleOptions 76 | }: SkeletonProps): ReactElement { 77 | const contextStyleOptions = React.useContext(SkeletonThemeContext); 78 | 79 | const propsStyleOptions = { ...originalPropsStyleOptions }; 80 | 81 | // DO NOT overwrite style options from the context if `propsStyleOptions` 82 | // has properties explicity set to undefined 83 | for (const [key, value] of Object.entries(originalPropsStyleOptions)) { 84 | if (typeof value === 'undefined') { 85 | delete propsStyleOptions[key as keyof typeof propsStyleOptions]; 86 | } 87 | } 88 | 89 | // Props take priority over context 90 | const styleOptions = { 91 | ...contextStyleOptions, 92 | ...propsStyleOptions, 93 | circle, 94 | }; 95 | 96 | // `styleProp` has the least priority out of everything 97 | const style = { 98 | ...styleProp, 99 | ...styleOptionsToCssProperties(styleOptions), 100 | }; 101 | 102 | let className = 'react-loading-skeleton'; 103 | if (customClassName) className += ` ${customClassName}`; 104 | 105 | const inline = styleOptions.inline ?? false; 106 | 107 | const elements: ReactElement[] = []; 108 | 109 | const countCeil = Math.ceil(count); 110 | 111 | for (let i = 0; i < countCeil; i++) { 112 | let thisStyle = style; 113 | 114 | if (countCeil > count && i === countCeil - 1) { 115 | // count is not an integer and we've reached the last iteration of 116 | // the loop, so add a "fractional" skeleton. 117 | // 118 | // For example, if count is 3.5, we've already added 3 full 119 | // skeletons, so now we add one more skeleton that is 0.5 times the 120 | // original width. 121 | 122 | const width = thisStyle.width ?? '100%'; // 100% is the default since that's what's in the CSS 123 | 124 | const fractionalPart = count % 1; 125 | 126 | const fractionalWidth = 127 | typeof width === 'number' 128 | ? width * fractionalPart 129 | : `calc(${width} * ${fractionalPart})`; 130 | 131 | thisStyle = { ...thisStyle, width: fractionalWidth }; 132 | } 133 | 134 | const skeletonSpan = ( 135 | 136 | ‌ 137 | 138 | ); 139 | 140 | if (inline) { 141 | elements.push(skeletonSpan); 142 | } else { 143 | // Without the
, the skeleton lines will all run together if 144 | // `width` is specified 145 | elements.push( 146 | 147 | {skeletonSpan} 148 |
149 |
150 | ); 151 | } 152 | } 153 | 154 | return ( 155 | 161 | {Wrapper 162 | ? elements.map((el, i) => {el}) 163 | : elements} 164 | 165 | ); 166 | } 167 | -------------------------------------------------------------------------------- /src/SkeletonStyleProps.ts: -------------------------------------------------------------------------------- 1 | export interface SkeletonStyleProps { 2 | baseColor?: string; 3 | highlightColor?: string; 4 | 5 | width?: string | number; 6 | height?: string | number; 7 | borderRadius?: string | number; 8 | inline?: boolean; 9 | 10 | duration?: number; 11 | direction?: 'ltr' | 'rtl'; 12 | enableAnimation?: boolean; 13 | 14 | customHighlightBackground?: string; 15 | } 16 | -------------------------------------------------------------------------------- /src/SkeletonTheme.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, PropsWithChildren } from 'react'; 2 | import { SkeletonStyleProps } from './SkeletonStyleProps.js'; 3 | import { SkeletonThemeContext } from './SkeletonThemeContext.js'; 4 | 5 | export type SkeletonThemeProps = PropsWithChildren; 6 | 7 | export function SkeletonTheme({ 8 | children, 9 | ...styleOptions 10 | }: SkeletonThemeProps): ReactElement { 11 | return ( 12 | 13 | {children} 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/SkeletonThemeContext.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { SkeletonStyleProps } from './SkeletonStyleProps.js'; 3 | 4 | /** 5 | * @internal 6 | */ 7 | export const SkeletonThemeContext = React.createContext({}); 8 | -------------------------------------------------------------------------------- /src/__stories__/Post.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Meta } from '@storybook/react'; 3 | import { SideBySide, Post } from './components/index.js'; 4 | 5 | export default { 6 | component: Post, 7 | title: 'Post', 8 | } satisfies Meta; 9 | 10 | export const Default: React.FC = () => ( 11 | 12 | 13 | 14 | 15 | ); 16 | 17 | export const Large: React.FC = () => ( 18 | 19 | 20 | 21 | 22 | ); 23 | -------------------------------------------------------------------------------- /src/__stories__/Skeleton.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | PropsWithChildren, 3 | useState, 4 | useEffect, 5 | useRef, 6 | ReactElement, 7 | } from 'react'; 8 | import ReactDOM from 'react-dom'; 9 | import { Meta } from '@storybook/react'; 10 | import { SideBySide, Box } from './components/index.js'; 11 | import { Skeleton } from '../Skeleton.js'; 12 | import './styles/Skeleton.stories.css'; 13 | 14 | export default { 15 | component: Skeleton, 16 | title: 'Skeleton', 17 | } satisfies Meta; 18 | 19 | export const Basic: React.FC = () => ; 20 | 21 | export const Inline: React.FC = () => ( 22 | 23 |
24 | 25 | 26 | 27 | 28 |
29 |
Some text for comparison
30 |
31 | ); 32 | 33 | export const InlineWithText: React.FC = () => ( 34 |
35 | Some random text Some more random text 36 |
37 | ); 38 | 39 | export const BlockWrapper: React.FC = () => ( 40 | 41 | 42 |
43 | A 44 | B 45 | C 46 | D 47 |
48 |
49 | ); 50 | 51 | function InlineWrapperWithMargin({ 52 | children, 53 | }: PropsWithChildren): ReactElement { 54 | return {children}; 55 | } 56 | 57 | export const InlineWrapper: React.FC = () => ( 58 |
59 | 60 |
61 | {[0, 1, 2, 3].map((i) => ( 62 |
63 | 69 |
70 | ))} 71 |
72 |
73 |
Some text for comparison
74 |
Some text for comparison
75 |
Some text for comparison
76 |
Some text for comparison
77 |
78 |
79 |
80 | ); 81 | 82 | export const DifferentDurations: React.FC = () => ( 83 |
84 | 85 | 86 | 87 | 88 |
89 | ); 90 | 91 | export const DifferentWidths: React.FC = () => ( 92 |
93 | 94 | 95 | 96 | 97 | 98 |
99 | ); 100 | 101 | export const DifferentHeights: React.FC = () => ( 102 |
103 | 104 | 105 | 106 | 107 | 108 |
109 | ); 110 | 111 | export const CustomStyles: React.FC = () => ( 112 | 116 | ); 117 | 118 | export const Circle: React.FC = () => ( 119 | 120 | ); 121 | 122 | export const DecimalCount: React.FC = () => ; 123 | 124 | export const DecimalCountPercentWidth: React.FC = () => ( 125 | 126 | ); 127 | 128 | export const DecimalCountInline: React.FC = () => ( 129 | 130 | ); 131 | 132 | // Use https://bennettfeely.com/clippy/ to try out other shapes 133 | const StarWrapper: React.FC> = ({ children }) => ( 134 |
143 | {children} 144 |
145 | ); 146 | 147 | export const Stars: React.FC = () => ( 148 | 155 | ); 156 | 157 | export const RightToLeft: React.FC = () => ; 158 | 159 | export const DisableAnimation: React.FC = () => { 160 | const [enabled, setEnabled] = useState(true); 161 | 162 | return ( 163 |
164 | 173 | 174 |
175 | ); 176 | }; 177 | 178 | export const PercentWidthInFlex: React.FC = () => ( 179 |
180 |

181 | This is a test for{' '} 182 | 183 | #61 184 | 185 | . The skeleton should take up 50% of the width of the turquoise flex 186 | container. 187 |

188 |
197 | 198 |
199 |
200 | ); 201 | 202 | export const FillEntireContainer: React.FC = () => ( 203 |
204 |

205 | This is a test for{' '} 206 | 207 | #31 208 | 209 | . The skeleton should fill the entire red container. The container has{' '} 210 | line-height: 1 to make it pixel perfect. 211 |

212 |
220 | 221 |
222 |
223 | ); 224 | 225 | interface HeightComparisonProps { 226 | title: string; 227 | lineHeight?: number; 228 | } 229 | 230 | function HeightComparison({ 231 | title, 232 | lineHeight = 3, 233 | children, 234 | }: PropsWithChildren): ReactElement { 235 | const wrapperRef = useRef(null); 236 | const [height, setHeight] = useState(); 237 | 238 | useEffect(() => { 239 | setHeight(wrapperRef.current?.clientHeight); 240 | }, []); 241 | 242 | return ( 243 |
244 |

{title}

245 | 246 |
247 | {children} 248 |
249 | 250 |
Expected height: 30
251 |
Actual height: {height}
252 |
253 | ); 254 | } 255 | 256 | export const HeightQuirk: React.FC = () => ( 257 |
258 |

259 | This is a demonstration of a Skeleton quirk that was reported in{' '} 260 | 261 | #23 262 | 263 | . 264 |

265 |

266 | If you set the Skeleton's height to 30px, the element containing the 267 | Skeleton will have a height of 31px, assuming the document's 268 | line-height is left at the default value. The height discrepancy increases 269 | with line-height. 270 |

271 |

272 | This example uses a large line-height to magnify the issue. It compares a 273 | Skeleton with height: 30px to a normal span tag with{' '} 274 | height: 30px; display: inline-block; line-height: 1;. The 275 | height discrepancy occurs in both cases which suggests that this is not a 276 | Skeleton bug. 277 |

278 |
279 | 280 | 281 | 282 | 283 | 291 | TEST 292 | 293 | 294 |
295 | 296 |

There are two ways to make the container exactly 30px tall.

297 |

Solution 1

298 |

299 | Set the line-height of the container to 1. 300 |

301 | 302 | 303 | 304 |

Solution 2

305 |

306 | Provide a containerClassName and apply the styles{' '} 307 | display: block; line-height: 1; to that class. 308 |

309 | 310 | 314 | 315 |
316 | ); 317 | 318 | export const ShadowDOM: React.FC = () => { 319 | const hostRef = useRef(null); 320 | const [portalDestination, setPortalDestination] = useState(); 321 | 322 | useEffect(() => { 323 | if (!hostRef.current) throw new Error('hostRef.current is null.'); 324 | 325 | const shadowRoot = hostRef.current.attachShadow({ mode: 'open' }); 326 | 327 | const myPortalDestination = document.createElement('div'); 328 | shadowRoot.append(myPortalDestination); 329 | 330 | setPortalDestination(myPortalDestination); 331 | }, []); 332 | 333 | // In a real app, you would insert the CSS into the Shadow DOM using one of 334 | // the strategies outlined here: 335 | // https://github.com/Wildhoney/ReactShadow#getting-started 336 | 337 | // This CSS does NOT need to be updated, the goal is just to prove that 338 | // Skeleton is capable of working in a Shadow DOM 339 | const skeletonCss = ` 340 | @keyframes react-loading-skeleton { 341 | 0% { 342 | background-position: -200px 0; 343 | } 344 | 100% { 345 | background-position: calc(200px + 100%) 0; 346 | } 347 | } 348 | 349 | .react-loading-skeleton { 350 | /* If either color is changed, Skeleton.tsx must be updated as well */ 351 | --base-color: #ebebeb; 352 | --highlight-color: #f5f5f5; 353 | 354 | background-color: var(--base-color); 355 | background-image: linear-gradient( 356 | 90deg, 357 | var(--base-color), 358 | var(--highlight-color), 359 | var(--base-color) 360 | ); 361 | 362 | width: 100%; 363 | background-size: 200px 100%; 364 | background-repeat: no-repeat; 365 | border-radius: 0.25rem; 366 | display: inline-block; 367 | line-height: 1; 368 | 369 | animation-name: react-loading-skeleton; 370 | animation-duration: 1.5s; 371 | animation-timing-function: ease-in-out; 372 | animation-iteration-count: infinite; 373 | } 374 | `; 375 | 376 | const shadowContent = ( 377 | <> 378 | 379 | 380 | 381 | ); 382 | 383 | return ( 384 |
385 |

386 | This story verifies that Skeleton works inside a Shadow DOM. An older 387 | version of Skeleton did not work inside the Shadow DOM according to{' '} 388 | 389 | #69 390 | 391 | . 392 |

393 |
394 | {portalDestination && 395 | ReactDOM.createPortal(shadowContent, portalDestination)} 396 |
397 | ); 398 | }; 399 | 400 | export const RegressionTest133 = () => ( 401 |
402 |

403 | Regression test for{' '} 404 | 405 | #133 406 | 407 | , in which the pseudoelement had the wrong vertical position. The animated 408 | highlight should cover the entire square. 409 |

410 |
411 | 415 |
416 |
417 | ); 418 | 419 | export const PrefersReducedMotion = () => ( 420 |
421 |

With prefers-reduced-motion, this skeleton should not be animated.

422 | 428 |
429 | ); 430 | 431 | export const HighlightWidth = () => ( 432 |
433 |

Default

434 | 438 |
439 | 440 |

Narrow highlight

441 | 445 |
446 | 447 |

Wide highlight

448 | 452 |
453 | 454 |

Fun gradient

455 | 459 |
460 | ); 461 | -------------------------------------------------------------------------------- /src/__stories__/SkeletonTheme.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Meta } from '@storybook/react'; 3 | import { Post, SideBySide, Box } from './components/index.js'; 4 | import { SkeletonTheme } from '../SkeletonTheme.js'; 5 | import { Skeleton } from '../Skeleton.js'; 6 | 7 | export default { 8 | component: SkeletonTheme, 9 | title: 'SkeletonTheme', 10 | } satisfies Meta; 11 | 12 | const darkBaseColor = '#333'; 13 | const darkHighlightColor = '#999'; 14 | const blueBaseColor = '#1D5CA6'; 15 | const blueHighlightColor = '#5294e0'; 16 | const lightBaseColor = '#c0c0c0'; 17 | const lightHighlightColor = '#A0A0A0'; 18 | 19 | export const WithColors: React.FC = () => ( 20 |
21 | 25 | 26 | 27 | 31 | 32 | 33 |
34 | ); 35 | 36 | export const NoBorderRadius: React.FC = () => ( 37 | 42 | 43 | 44 | ); 45 | 46 | export const LightAndDarkThemes: React.FC = () => { 47 | const [theme, setTheme] = React.useState<'light' | 'dark'>('light'); 48 | 49 | const handleToggle = () => { 50 | setTheme((oldTheme) => (oldTheme === 'light' ? 'dark' : 'light')); 51 | }; 52 | 53 | const skeletonColor = theme === 'light' ? darkBaseColor : lightBaseColor; 54 | const skeletonHighlight = 55 | theme === 'light' ? darkHighlightColor : lightHighlightColor; 56 | 57 | const backgroundColor = theme === 'light' ? 'white' : '#333'; 58 | const color = theme === 'light' ? 'unset' : '#eee'; 59 | 60 | return ( 61 |
62 | 65 | 66 | 70 | 71 | 72 |
73 | A 74 | B 75 | C 76 | D 77 | E 78 |
79 |
80 |
81 | ); 82 | }; 83 | 84 | export const PropsExplicitlySetToUndefined: React.FC = () => ( 85 |
86 |

87 | This is a test for{' '} 88 | 89 | #128 90 | 91 | . The skeleton should have Christmas colors. 92 |

93 | 94 | 95 | 96 |
97 | ); 98 | -------------------------------------------------------------------------------- /src/__stories__/components/Box.tsx: -------------------------------------------------------------------------------- 1 | import React, { PropsWithChildren, ReactElement } from 'react'; 2 | 3 | export const Box = ({ children }: PropsWithChildren): ReactElement => ( 4 |
14 | {children} 15 |
16 | ); 17 | -------------------------------------------------------------------------------- /src/__stories__/components/Post.tsx: -------------------------------------------------------------------------------- 1 | import React, { PropsWithChildren, ReactElement } from 'react'; 2 | import { Skeleton } from '../../Skeleton.js'; 3 | 4 | export const postContent = 5 | 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' + 6 | 'Vestibulum nec justo feugiat, auctor nunc ac, volutpat arcu. Suspendisse ' + 7 | 'faucibus aliquam ante, sit amet iaculis dolor posuere et. In ut placerat leo.'; 8 | 9 | export interface PostProps { 10 | loading: boolean; 11 | size?: 'small' | 'large'; 12 | } 13 | 14 | export function Post({ 15 | loading, 16 | size = 'small', 17 | }: PropsWithChildren): ReactElement { 18 | return ( 19 |
27 |

{loading ? : 'A Title'}

28 |

{loading ? : postContent}

29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/__stories__/components/SideBySide.tsx: -------------------------------------------------------------------------------- 1 | import React, { PropsWithChildren, ReactElement, ReactNode } from 'react'; 2 | 3 | const style = { 4 | alignItems: 'flex-start', 5 | display: 'flex', 6 | justifyContent: 'center', 7 | }; 8 | 9 | const arrowStyle = { 10 | alignSelf: 'center', 11 | fontSize: 20, 12 | padding: '0 1.25rem', 13 | lineHeight: 0.5, 14 | }; 15 | 16 | export function SideBySide({ 17 | children, 18 | }: PropsWithChildren): ReactElement { 19 | const childrenWithArrows: ReactNode[] = []; 20 | 21 | React.Children.forEach(children, (child, index) => { 22 | if (index > 0) { 23 | childrenWithArrows.push( 24 | // eslint-disable-next-line react/no-array-index-key 25 |
26 | → 27 |
28 | ); 29 | } 30 | childrenWithArrows.push(child); 31 | }); 32 | 33 | return
{childrenWithArrows}
; 34 | } 35 | -------------------------------------------------------------------------------- /src/__stories__/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './SideBySide.js'; 2 | export * from './Post.js'; 3 | export * from './Box.js'; 4 | -------------------------------------------------------------------------------- /src/__stories__/styles/Skeleton.stories.css: -------------------------------------------------------------------------------- 1 | .w-50 { 2 | width: 50%; 3 | } 4 | 5 | .height-quirk-custom-container { 6 | display: block; 7 | line-height: 1; 8 | } 9 | -------------------------------------------------------------------------------- /src/__tests__/Skeleton.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { PropsWithChildren } from 'react'; 2 | import { it, expect, afterEach } from 'vitest'; 3 | import { cleanup, render, screen } from '@testing-library/react'; 4 | import { Skeleton } from '../Skeleton.js'; 5 | import { 6 | getAllSkeletons, 7 | getSkeleton, 8 | hasLineBreak, 9 | skeletonSelector, 10 | } from './__helpers__/index.js'; 11 | 12 | afterEach(cleanup); 13 | 14 | it('renders a skeleton', () => { 15 | render(); 16 | 17 | const skeletonElements = getAllSkeletons(); 18 | 19 | expect(skeletonElements).toHaveLength(1); 20 | expect(skeletonElements[0]).toBeVisible(); 21 | 22 | // No inline styles should be rendered by default 23 | expect(skeletonElements[0]).not.toHaveAttribute('style'); 24 | }); 25 | 26 | it('renders the required number of skeletons', () => { 27 | render(); 28 | 29 | const skeletonElements = getAllSkeletons(); 30 | 31 | expect(skeletonElements).toHaveLength(4); 32 | }); 33 | 34 | it('changes the color of the skeleton', () => { 35 | render(); 36 | 37 | const skeleton = getSkeleton(); 38 | 39 | expect(skeleton.style.getPropertyValue('--base-color')).toBe('purple'); 40 | expect(skeleton.style.getPropertyValue('--highlight-color')).toBe('red'); 41 | }); 42 | 43 | it('renders a skeleton with styles', () => { 44 | const style = { borderRadius: 10, height: 50, width: 50 }; 45 | render(); 46 | 47 | const skeleton = getSkeleton(); 48 | 49 | expect(skeleton).toHaveStyle({ 50 | borderRadius: `${style.borderRadius}px`, 51 | height: `${style.height}px`, 52 | width: `${style.width}px`, 53 | }); 54 | }); 55 | 56 | it('prioritizes explicit props over style prop', () => { 57 | const style = { borderRadius: 10, height: 10, width: 10 }; 58 | render(); 59 | 60 | const skeleton = getSkeleton(); 61 | 62 | expect(skeleton).toHaveStyle({ 63 | borderRadius: '20px', 64 | height: '21px', 65 | width: '22px', 66 | }); 67 | }); 68 | 69 | it('ignores borderRadius if circle=true', () => { 70 | render(); 71 | 72 | expect(getSkeleton()).toHaveStyle({ borderRadius: '50%' }); 73 | }); 74 | 75 | it('adds a line break when inline is false', () => { 76 | const { rerender } = render(); 77 | expect(hasLineBreak()).toBe(true); 78 | 79 | rerender(); 80 | expect(hasLineBreak()).toBe(true); 81 | 82 | rerender(); 83 | expect(hasLineBreak()).toBe(false); 84 | }); 85 | 86 | it('disables the animation if and only if enableAnimation is false', () => { 87 | const { rerender } = render(); 88 | expect(getSkeleton().style.getPropertyValue('--pseudo-element-display')).toBe( 89 | '' 90 | ); 91 | expect(screen.getByTestId('container')).toHaveAttribute('aria-busy', 'true'); 92 | 93 | rerender(); 94 | expect(getSkeleton().style.getPropertyValue('--pseudo-element-display')).toBe( 95 | '' 96 | ); 97 | expect(screen.getByTestId('container')).toHaveAttribute('aria-busy', 'true'); 98 | 99 | rerender(); 100 | expect(getSkeleton().style.getPropertyValue('--pseudo-element-display')).toBe( 101 | 'none' 102 | ); 103 | expect(screen.getByTestId('container')).toHaveAttribute('aria-busy', 'false'); 104 | }); 105 | 106 | it('uses a custom className', () => { 107 | render(); 108 | 109 | const skeleton = getSkeleton(); 110 | 111 | expect(skeleton).toHaveClass('react-loading-skeleton'); 112 | expect(skeleton).toHaveClass('test-class'); 113 | }); 114 | 115 | it('applies the containerClassName and containerTestId', () => { 116 | render( 117 | 118 | ); 119 | 120 | const container = screen.getByTestId('myTestId'); 121 | expect(container).toHaveClass('test-class'); 122 | }); 123 | 124 | it('renders a skeleton with a wrapper', () => { 125 | const Wrapper: React.FC> = ({ children }) => ( 126 |
{children}
127 | ); 128 | 129 | render(); 130 | 131 | const box = document.querySelector('.box'); 132 | if (!box) throw new Error('box is null.'); 133 | 134 | expect(box.querySelector(skeletonSelector)).toBeVisible(); 135 | }); 136 | 137 | it('renders a half-width skeleton when count = 1.5', () => { 138 | render(); 139 | 140 | const skeletons = getAllSkeletons(); 141 | expect(skeletons).toHaveLength(2); 142 | 143 | expect(skeletons[0]).toHaveStyle({ width: '' }); 144 | expect(skeletons[1]).toHaveStyle({ width: 'calc(100% * 0.5)' }); 145 | }); 146 | 147 | it('renders a 3/4-width skeleton when count = 1.75 and width is set in pixels', () => { 148 | render(); 149 | 150 | const skeletons = getAllSkeletons(); 151 | expect(skeletons).toHaveLength(2); 152 | 153 | expect(skeletons[0]).toHaveStyle({ width: '100px' }); 154 | expect(skeletons[1]).toHaveStyle({ width: '75px' }); 155 | }); 156 | -------------------------------------------------------------------------------- /src/__tests__/SkeletonTheme.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { it, expect, afterEach } from 'vitest'; 4 | import { cleanup, render, screen } from '@testing-library/react'; 5 | import { SkeletonTheme } from '../SkeletonTheme.js'; 6 | import { Skeleton } from '../Skeleton.js'; 7 | import { getSkeleton } from './__helpers__/index.js'; 8 | 9 | afterEach(cleanup); 10 | 11 | it('does not render anything', () => { 12 | render( 13 |
14 | 15 |
16 | ); 17 | 18 | expect(screen.getByTestId('container')).toBeEmptyDOMElement(); 19 | }); 20 | 21 | it('styles the skeleton', () => { 22 | render( 23 | 24 | 25 | 26 | ); 27 | 28 | const skeleton = getSkeleton(); 29 | expect(skeleton).toHaveStyle({ borderRadius: '1rem' }); 30 | expect(skeleton.style.getPropertyValue('--base-color')).toBe('black'); 31 | }); 32 | 33 | it('is overridden by Skeleton props', () => { 34 | render( 35 | 36 | 37 | 38 | ); 39 | 40 | const skeleton = getSkeleton(); 41 | expect(skeleton).toHaveStyle({ borderRadius: '2rem' }); 42 | expect(skeleton.style.getPropertyValue('--base-color')).toBe('black'); 43 | }); 44 | 45 | it('styles the skeleton through a portal', () => { 46 | const portalDestination = document.createElement('div'); 47 | document.body.append(portalDestination); 48 | 49 | render( 50 | 51 | {ReactDOM.createPortal(, portalDestination)} 52 | 53 | ); 54 | 55 | const skeleton = getSkeleton(); 56 | expect(skeleton).toHaveStyle({ borderRadius: '1rem' }); 57 | expect(skeleton.style.getPropertyValue('--base-color')).toBe('black'); 58 | }); 59 | 60 | // Regression test 61 | it('is not blocked by setting Skeleton props to undefined', () => { 62 | render( 63 | 64 | 65 | 66 | ); 67 | 68 | const skeleton = getSkeleton(); 69 | expect(skeleton.style.getPropertyValue('--base-color')).toBe('green'); 70 | expect(skeleton.style.getPropertyValue('--highlight-color')).toBe('red'); 71 | }); 72 | -------------------------------------------------------------------------------- /src/__tests__/__helpers__/index.ts: -------------------------------------------------------------------------------- 1 | import matchers from '@testing-library/jest-dom/matchers.js'; 2 | import { expect } from 'vitest'; 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any 5 | expect.extend(matchers as any); 6 | 7 | export const skeletonSelector = 'span.react-loading-skeleton'; 8 | 9 | export function getAllSkeletons(): HTMLElement[] { 10 | return Array.from(document.querySelectorAll(skeletonSelector)); 11 | } 12 | 13 | export function getSkeleton(): HTMLElement { 14 | const skeleton = document.querySelector(skeletonSelector); 15 | if (!skeleton) throw new Error('Could not find skeleton.'); 16 | 17 | return skeleton; 18 | } 19 | 20 | export function hasLineBreak(): boolean { 21 | return !!document.querySelector('br'); 22 | } 23 | -------------------------------------------------------------------------------- /src/__tests__/index.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { it, expect } from 'vitest'; 3 | import Skeleton, { 4 | SkeletonTheme, 5 | SkeletonThemeProps, 6 | SkeletonProps, 7 | } from '../index.js'; 8 | 9 | it('exports Skeleton and friends', () => { 10 | expect(typeof Skeleton).toBe('function'); 11 | expect(typeof SkeletonTheme).toBe('function'); 12 | 13 | /* eslint-disable @typescript-eslint/no-unused-vars */ 14 | const skeletonProps: SkeletonProps = { count: 3, borderRadius: '1rem' }; 15 | const skeletonThemeProps: SkeletonThemeProps = { 16 | children:
, 17 | baseColor: '#3a3a3a', 18 | highlightColor: 'white', 19 | }; 20 | /* eslint-enable @typescript-eslint/no-unused-vars */ 21 | }); 22 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Skeleton } from './Skeleton.js'; 2 | 3 | export default Skeleton; 4 | export * from './SkeletonStyleProps.js'; 5 | export * from './SkeletonTheme.js'; 6 | export type { SkeletonProps } from './Skeleton.js'; 7 | -------------------------------------------------------------------------------- /src/skeleton.css: -------------------------------------------------------------------------------- 1 | @keyframes react-loading-skeleton { 2 | 100% { 3 | transform: translateX(100%); 4 | } 5 | } 6 | 7 | .react-loading-skeleton { 8 | --base-color: #ebebeb; 9 | --highlight-color: #f5f5f5; 10 | --animation-duration: 1.5s; 11 | --animation-direction: normal; 12 | --pseudo-element-display: block; /* Enable animation */ 13 | 14 | background-color: var(--base-color); 15 | 16 | width: 100%; 17 | border-radius: 0.25rem; 18 | display: inline-flex; 19 | line-height: 1; 20 | 21 | position: relative; 22 | user-select: none; 23 | overflow: hidden; 24 | } 25 | 26 | .react-loading-skeleton::after { 27 | content: ' '; 28 | display: var(--pseudo-element-display); 29 | position: absolute; 30 | top: 0; 31 | left: 0; 32 | right: 0; 33 | height: 100%; 34 | background-repeat: no-repeat; 35 | background-image: var( 36 | --custom-highlight-background, 37 | linear-gradient( 38 | 90deg, 39 | var(--base-color) 0%, 40 | var(--highlight-color) 50%, 41 | var(--base-color) 100% 42 | ) 43 | ); 44 | transform: translateX(-100%); 45 | 46 | animation-name: react-loading-skeleton; 47 | animation-direction: var(--animation-direction); 48 | animation-duration: var(--animation-duration); 49 | animation-timing-function: ease-in-out; 50 | animation-iteration-count: infinite; 51 | } 52 | 53 | @media (prefers-reduced-motion) { 54 | .react-loading-skeleton { 55 | --pseudo-element-display: none; /* Disable animation */ 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Language and Environment */ 4 | "target": "ES2018", 5 | "jsx": "react", 6 | 7 | /* Modules */ 8 | "module": "nodenext", 9 | "moduleResolution": "nodenext", 10 | 11 | /* Emit */ 12 | "declaration": true, 13 | "outDir": "dist", 14 | "noEmit": true, 15 | "noEmitOnError": true, 16 | 17 | /* Interop Constraints */ 18 | "isolatedModules": true, 19 | "esModuleInterop": true, 20 | "forceConsistentCasingInFileNames": true, 21 | 22 | /* Type Checking */ 23 | "strict": true, 24 | "noFallthroughCasesInSwitch": true, 25 | 26 | /* Completeness */ 27 | "skipLibCheck": true 28 | }, 29 | "include": ["src"] 30 | } 31 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | import checker from 'vite-plugin-checker'; 3 | 4 | import react from '@vitejs/plugin-react-swc'; 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | plugins: [checker({ typescript: true }), react()], 9 | test: { 10 | environment: 'jsdom', 11 | }, 12 | }); 13 | --------------------------------------------------------------------------------