├── .editorconfig
├── .eslintignore
├── .github
├── ISSUE_TEMPLATE
│ ├── bad-design-choice.md
│ ├── bug-report.md
│ └── feature_request.md
└── workflows
│ └── playwright.yml
├── .gitignore
├── CHANGELOG.md
├── LICENSE
├── README.md
├── apps
├── storybook
│ ├── .gitignore
│ ├── .storybook
│ │ ├── main.js
│ │ └── preview.js
│ ├── LICENSE
│ ├── README.md
│ ├── components.json
│ ├── index.html
│ ├── package.json
│ ├── public
│ │ └── vite.svg
│ ├── src
│ │ ├── assets
│ │ │ ├── font
│ │ │ │ ├── OpenSans-Light-webfont.eot
│ │ │ │ ├── OpenSans-Light-webfont.svg
│ │ │ │ ├── OpenSans-Light-webfont.ttf
│ │ │ │ ├── OpenSans-Light-webfont.woff
│ │ │ │ ├── OpenSans-Regular-webfont.eot
│ │ │ │ ├── OpenSans-Regular-webfont.svg
│ │ │ │ ├── OpenSans-Regular-webfont.ttf
│ │ │ │ └── OpenSans-Regular-webfont.woff
│ │ │ ├── icon
│ │ │ │ ├── percolate.eot
│ │ │ │ ├── percolate.svg
│ │ │ │ ├── percolate.ttf
│ │ │ │ └── percolate.woff
│ │ │ └── react.svg
│ │ ├── components
│ │ │ └── ui
│ │ │ │ └── input-otp.tsx
│ │ ├── globals.css
│ │ ├── lib
│ │ │ └── utils.ts
│ │ └── stories
│ │ │ ├── Button.stories.ts
│ │ │ ├── Button.tsx
│ │ │ ├── InputOTP.stories.tsx
│ │ │ ├── Introduction.mdx
│ │ │ ├── assets
│ │ │ ├── code-brackets.svg
│ │ │ ├── colors.svg
│ │ │ ├── comments.svg
│ │ │ ├── direction.svg
│ │ │ ├── flow.svg
│ │ │ ├── plugin.svg
│ │ │ ├── repo.svg
│ │ │ └── stackalt.svg
│ │ │ └── button.css
│ ├── tailwind.config.js
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.js
├── test
│ ├── .eslintrc.json
│ ├── .gitignore
│ ├── README.md
│ ├── next.config.mjs
│ ├── package.json
│ ├── playwright.config.ts
│ ├── postcss.config.js
│ ├── public
│ │ ├── next.svg
│ │ └── vercel.svg
│ ├── src
│ │ ├── app
│ │ │ ├── base
│ │ │ │ └── page.tsx
│ │ │ ├── favicon.ico
│ │ │ ├── globals.css
│ │ │ ├── layout.tsx
│ │ │ ├── page.tsx
│ │ │ ├── props
│ │ │ │ └── page.tsx
│ │ │ ├── with-autofocus
│ │ │ │ └── page.tsx
│ │ │ └── with-on-complete
│ │ │ │ └── page.tsx
│ │ ├── components
│ │ │ └── base-input.tsx
│ │ ├── lib
│ │ │ ├── fonts.ts
│ │ │ └── utils.ts
│ │ └── tests
│ │ │ ├── base.delete-word.spec.ts
│ │ │ ├── base.props.spec.ts
│ │ │ ├── base.render.spec.ts
│ │ │ ├── base.selections.spec.ts
│ │ │ ├── base.slot.spec.ts
│ │ │ ├── base.typing.spec.ts
│ │ │ ├── util
│ │ │ └── modifier.ts
│ │ │ ├── with-autofocus.spec.ts
│ │ │ └── with-on-complete.spec.ts
│ ├── tailwind.config.ts
│ └── tsconfig.json
└── website
│ ├── .eslintrc.json
│ ├── .gitignore
│ ├── README.md
│ ├── components.json
│ ├── next.config.mjs
│ ├── package.json
│ ├── pnpm-lock.yaml
│ ├── postcss.config.js
│ ├── public
│ ├── next.svg
│ ├── og.jpg
│ ├── site.webmanifest
│ └── vercel.svg
│ ├── src
│ ├── app
│ │ ├── (local-pages)
│ │ │ ├── example-auto-submit
│ │ │ │ ├── page.tsx
│ │ │ │ └── server
│ │ │ │ │ └── form-action.ts
│ │ │ ├── example-playground
│ │ │ │ ├── code.tsx
│ │ │ │ ├── component.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── layout.tsx
│ │ │ ├── raw-input
│ │ │ │ └── page.tsx
│ │ │ └── shadcn
│ │ │ │ ├── page.tsx
│ │ │ │ ├── pwmb
│ │ │ │ └── page.tsx
│ │ │ │ └── static
│ │ │ │ └── page.tsx
│ │ ├── (pages)
│ │ │ ├── (home)
│ │ │ │ ├── _components
│ │ │ │ │ ├── confetti.tsx
│ │ │ │ │ └── showcase.tsx
│ │ │ │ └── page.tsx
│ │ │ └── layout.tsx
│ │ ├── favicon.ico
│ │ └── globals.css
│ ├── components
│ │ ├── code.tsx
│ │ ├── copy-button.tsx
│ │ ├── icons.tsx
│ │ ├── mode-toggle.tsx
│ │ ├── page-header.tsx
│ │ ├── provider.tsx
│ │ ├── site-footer.tsx
│ │ ├── site-header.tsx
│ │ └── ui
│ │ │ ├── button.tsx
│ │ │ ├── dropdown-menu.tsx
│ │ │ ├── input-otp.tsx
│ │ │ ├── scroll-area.tsx
│ │ │ └── sonner.tsx
│ ├── config
│ │ └── site.ts
│ └── lib
│ │ ├── fonts.ts
│ │ └── utils.ts
│ ├── tailwind.config.ts
│ └── tsconfig.json
├── package.json
├── packages
└── input-otp
│ ├── package.json
│ ├── src
│ ├── index.ts
│ ├── input.tsx
│ ├── regexp.ts
│ ├── sync-timeouts.ts
│ ├── types.ts
│ ├── use-previous.ts
│ └── use-pwm-badge.tsx
│ ├── tsconfig.json
│ └── tsup.config.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
└── turbo.json
/.editorconfig:
--------------------------------------------------------------------------------
1 | # EditorConfig is awesome: https://EditorConfig.org
2 |
3 | # top-most EditorConfig file
4 | root = true
5 |
6 | [*]
7 | indent_style = space
8 | indent_size = 2
9 | end_of_line = lf
10 | charset = utf-8
11 | trim_trailing_whitespace = true
12 | insert_final_newline = true
13 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | .eslintrc*
2 | *.config.js
3 | /.jest/*
4 | /coverage/*
5 | /dist/*
6 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bad-design-choice.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bad design choice
3 | about: Describe a preference/default that was either 1. ignored 2. blocked or 3. wrong
4 | from the API perspective.
5 | title: "[DX/UX]"
6 | labels: invalid
7 | assignees: ''
8 |
9 | ---
10 |
11 | Example:
12 |
13 | I'm unhappy with ___ design/API choice because ___. The ___ could be better if you ___.
14 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug-report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Describe your bug. Which version?
4 | title: "[BUG]"
5 | labels: bug
6 | assignees: guilhermerodz
7 |
8 | ---
9 |
10 | Issue tracker is **ONLY** used for reporting bugs. New features should be discussed on our slack channel. Please use [stackoverflow](https://stackoverflow.com) for supporting issues.
11 |
12 |
13 |
14 | ## Version of the library: 1.x
15 |
16 | ## Expected Behavior
17 |
18 |
19 | ## Current Behavior
20 |
21 |
22 | ## Possible Solution
23 |
24 |
25 | ## Steps to Reproduce
26 |
27 |
28 | 1.
29 | 2.
30 | 3.
31 | 4.
32 |
33 | ## Context (Environment)
34 |
35 |
36 |
37 |
38 |
39 | ## Detailed Description
40 |
41 |
42 | ## Possible Implementation
43 |
44 |
--------------------------------------------------------------------------------
/.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 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/.github/workflows/playwright.yml:
--------------------------------------------------------------------------------
1 | name: Playwright Tests
2 | on:
3 | push:
4 | branches: [main, master]
5 | pull_request:
6 | branches: [main, master]
7 | jobs:
8 | test:
9 | timeout-minutes: 60
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v3
13 | - uses: actions/setup-node@v3
14 | with:
15 | node-version: 18.17.1
16 | - run: npm install -g pnpm@8.7.1
17 |
18 | - run: pnpm install
19 | - run: npx playwright install --with-deps
20 | - run: pnpm test || exit 1
21 |
22 | - uses: actions/upload-artifact@v3
23 | if: always()
24 | with:
25 | name: playwright-report
26 | path: playwright-report/
27 | retention-days: 30
28 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 | dist
3 |
4 | # dependencies
5 | node_modules
6 | .pnp
7 | .pnp.js
8 |
9 | # testing
10 | coverage
11 | test-results/
12 | playwright-report/
13 | playwright/.cache/
14 |
15 | # next.js
16 | .next/
17 | out/
18 | build
19 | next-env.d.ts
20 |
21 | # misc
22 | .DS_Store
23 | *.pem
24 |
25 | # debug
26 | npm-debug.log*
27 | yarn-debug.log*
28 | yarn-error.log*
29 | .pnpm-debug.log*
30 |
31 | # local env files
32 | .env*.local
33 |
34 | # typescript
35 | *.tsbuildinfo
36 |
37 | # turbo
38 | .turbo
39 |
40 | # post-build
41 | packages/input-otp/README.md
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## [1.2.4]
4 |
5 | - fix(input): prevent single caret selection on deletion/cutting
6 |
7 | ## [1.2.3]
8 |
9 | - fix(input/css): specify `color: transparent !important` for `::selection` modifier
10 | - fix(input/node-env): check for CSS supports api before calling fn
11 |
12 | ## [1.2.2]
13 |
14 | - chore(input): remove experimental flag `pushPasswordManagerStrategy`
15 |
16 | ## [1.2.1]
17 |
18 | - fix(input): use `color` not `text` for autofillStyles
19 | - chore(input): keep support for prop pushPasswordManagerStrategy="experimental-no-flickering"
20 | - fix(input): prevent layout expansion when password managers aren't there and remove "experimental-no-flickering" strategy
21 |
22 | ## [1.2.0]
23 |
24 | - chore(input): don't restrict inputMode typing
25 |
26 | ## [1.2.0-beta.1]
27 |
28 | - fix(input): renderfn typing
29 |
30 | ## [1.2.0-beta.0]
31 |
32 | - feat(input): add context option
33 | - chore(input): remove unused type `SelectionType`
34 |
35 | ## [1.1.0]
36 |
37 | - feat(input/no-js): allow opting out of no-js fallback
38 | - fix(input/no-js): move noscript to the top
39 | - chore(input): optimize use-badge
40 | - fix(input): set no extra width on default noscript css fallback
41 | - fix(input): check window during ssr
42 | - fix(input/ios): add right: 1px to compensate left: -1px
43 | - chore(input/ios): revert paste listener (re-add)
44 | - chore(input): always trigger selection menu on ios
45 | - perf(input): prevent trackPWMBadge when strategy is none
46 | - fix(input): do not skip left slot
47 | - fix(input): do not skip left slot when pressing arrowleft after insert mode
48 | - fix(input): reinforce wrapper to pointerEvents none
49 | - feat(input): add experimental push pwm badge
50 | - chore(input): rename prop to pushPasswordManagerStrategy
51 | - chore(input): move focus logic to _focusListener
52 | - fix(input): reinforce no box shadows
53 | - perf(input): rewrite core in a single event listener
54 | - fix(input): safe insert css rules
55 | - fix(input): prevent layout shift caused by password managers
56 | - feat(input): add pwm badge space detector
57 | - feat(input): add passwordManagerBehavior prop
58 | - fix(input): forcefully remove :autofill
59 | - feat(input): track password managers
60 |
61 | ## [1.0.1]
62 |
63 | - fix(input): immediately update selection after paste
64 | - fix(input): hide selection on iOS webkit
65 |
66 | ## [1.0.0]
67 |
68 | - fix(input/firefox): use setselectionrange direction:backwards
69 |
70 | ## [0.3.31-beta]
71 |
72 | No input scope changes for this version.
73 |
74 | ## [0.3.3-beta]
75 |
76 | No input scope changes for this version.
77 |
78 | ## [0.3.2-beta]
79 |
80 | No input scope changes for this version.
81 |
82 | ## [0.3.11-beta]
83 |
84 | No input scope changes for this version.
85 |
86 | ## [0.3.1-beta]
87 |
88 | No input scope changes for this version.
89 |
90 | ## [0.2.4]
91 |
92 | - chore(input): always focus onContainerClick
93 |
94 | ## [0.2.1]
95 |
96 | - fix(input): do not trigger `onComplete` twice
97 |
98 | ## [0.2]
99 |
100 | No input scope changes for this version.
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Guilherme Rodz
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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # The only accessible & unstyled & full featured Input OTP component in the Web.
2 |
3 | ### OTP Input for React 🔐 by [@guilhermerodz](https://twitter.com/guilherme_rodz)
4 |
5 | https://github.com/guilhermerodz/input-otp/assets/10366880/753751f5-eda8-4145-a4b9-7ef51ca5e453
6 |
7 | ## Usage
8 |
9 | ```bash
10 | npm install input-otp
11 | ```
12 |
13 | Then import the component.
14 |
15 | ```diff
16 | +'use client'
17 | +import { OTPInput } from 'input-otp'
18 |
19 | function MyForm() {
20 | return
23 | }
24 | ```
25 |
26 | ## Default example
27 |
28 | The example below uses `tailwindcss` `@shadcn/ui` `tailwind-merge` `clsx`:
29 |
30 | ```tsx
31 | 'use client'
32 | import { OTPInput, SlotProps } from 'input-otp'
33 | (
37 | <>
38 |
39 | {slots.slice(0, 3).map((slot, idx) => (
40 |
41 | ))}
42 |
43 |
44 |
45 |
46 |
47 | {slots.slice(3).map((slot, idx) => (
48 |
49 | ))}
50 |
51 | >
52 | )}
53 | />
54 |
55 | // Feel free to copy. Uses @shadcn/ui tailwind colors.
56 | function Slot(props: SlotProps) {
57 | return (
58 |
69 | {props.char !== null &&
{props.char}
}
70 | {props.hasFakeCaret &&
}
71 |
72 | )
73 | }
74 |
75 | // You can emulate a fake textbox caret!
76 | function FakeCaret() {
77 | return (
78 |
81 | )
82 | }
83 |
84 | // Inspired by Stripe's MFA input.
85 | function FakeDash() {
86 | return (
87 |
90 | )
91 | }
92 |
93 | // tailwind.config.ts for the blinking caret animation.
94 | const config = {
95 | theme: {
96 | extend: {
97 | keyframes: {
98 | 'caret-blink': {
99 | '0%,70%,100%': { opacity: '1' },
100 | '20%,50%': { opacity: '0' },
101 | },
102 | },
103 | animation: {
104 | 'caret-blink': 'caret-blink 1.2s ease-out infinite',
105 | },
106 | },
107 | },
108 | }
109 |
110 | // Small utility to merge class names.
111 | import { clsx } from 'clsx'
112 | import { twMerge } from 'tailwind-merge'
113 |
114 | import type { ClassValue } from 'clsx'
115 |
116 | export function cn(...inputs: ClassValue[]) {
117 | return twMerge(clsx(inputs))
118 | }
119 | ```
120 |
121 | ## How it works
122 |
123 | There's currently no native OTP/2FA/MFA input in HTML, which means people are either going with 1. a simple input design or 2. custom designs like this one.
124 | This library works by rendering an invisible input as a sibling of the slots, contained by a `relative`ly positioned parent (the container root called _OTPInput_).
125 |
126 | ## Features
127 |
128 | This is the most complete OTP input on the web. It's fully featured
129 |
130 |
131 | Supports iOS + Android copy-paste-cut
132 |
133 | https://github.com/guilhermerodz/input-otp/assets/10366880/bdbdc96a-23da-4e89-bff8-990e6a1c4c23
134 |
135 |
136 |
137 |
138 | Automatic OTP code retrieval from transport (e.g SMS)
139 |
140 | By default, this input uses `autocomplete='one-time-code'` and it works as it's a single input.
141 |
142 | https://github.com/guilhermerodz/input-otp/assets/10366880/5705dac6-9159-443b-9c27-b52e93c60ea8
143 |
144 |
145 |
146 |
147 | Supports screen readers (a11y)
148 |
149 | Stripe was my first inspiration to build this library.
150 |
151 | Take a look at Stripe's input. The screen reader does not behave like it normally should on a normal single input.
152 | That's because Stripe's solution is to render a 1-digit input with "clone-divs" rendering a single char per div.
153 |
154 | https://github.com/guilhermerodz/input-otp/assets/10366880/3d127aef-147c-4f28-9f6c-57a357a802d0
155 |
156 | So we're rendering a single input with invisible/transparent colors instead.
157 | The screen reader now gets to read it, but there is no appearance. Feel free to build whatever UI you want:
158 |
159 | https://github.com/guilhermerodz/input-otp/assets/10366880/718710f0-2198-418c-8fa0-46c05ae5475d
160 |
161 |
162 |
163 |
164 | Supports all keybindings
165 |
166 | Should be able to support all keybindings of a common text input as it's an input.
167 |
168 | https://github.com/guilhermerodz/input-otp/assets/10366880/185985c0-af64-48eb-92f9-2e59be9eb78f
169 |
170 |
171 |
172 |
173 | Automatically optimizes for password managers
174 |
175 |
176 | For password managers such as LastPass, 1Password, Dashlane or Bitwarden, `input-otp` will automatically detect them in the page and increase input width by ~40px to trick the password manager's browser extension and prevent the badge from rendering to the last/right slot of the input.
177 |
178 |
179 |
180 | - **This feature is optional and it's enabled by default. You can disable this optimization by adding `pushPasswordManagerStrategy="none"`.**
181 | - **This feature does not cause visible layout shift.**
182 |
183 | ### Auto tracks if the input has space in the right side for the badge
184 |
185 | https://github.com/guilhermerodz/input-otp/assets/10366880/bf01af88-1f82-463e-adf4-54a737a92f59
186 |
187 |
188 |
189 | ## API Reference
190 |
191 | ### OTPInput
192 |
193 | The root container. Define settings for the input via props. Then, use the `render` prop to create the slots.
194 |
195 | #### Props
196 |
197 | ```ts
198 | type OTPInputProps = {
199 | // The number of slots
200 | maxLength: number
201 |
202 | // Render function creating the slots
203 | render: (props: RenderProps) => React.ReactElement
204 | // PS: Render prop is mandatory, except in cases
205 | // you'd like to consume the original Context API.
206 | // (search for Context in this docs)
207 |
208 | // The class name for the root container
209 | containerClassName?: string
210 |
211 | // Value state controlling the input
212 | value?: string
213 | // Setter for the controlled value (or callback for uncontrolled value)
214 | onChange?: (newValue: string) => unknown
215 |
216 | // Callback when the input is complete
217 | onComplete?: (...args: any[]) => unknown
218 |
219 | // Where is the text located within the input
220 | // Affects click-holding or long-press behavior
221 | // Default: 'left'
222 | textAlign?: 'left' | 'center' | 'right'
223 |
224 | // Virtual keyboard appearance on mobile
225 | // Default: 'numeric'
226 | inputMode?: 'numeric' | 'text' | 'decimal' | 'tel' | 'search' | 'email' | 'url'
227 |
228 | // Enabled by default, it's an optional
229 | // strategy for detecting Password Managers
230 | // in the page and then shifting their
231 | // badges to the right side, outside the input.
232 | pushPasswordManagerStrategy?:
233 | | 'increase-width'
234 | | 'none'
235 |
236 | // Enabled by default, it's an optional
237 | // fallback for pages without JS.
238 | // This is a CSS string. Write your own
239 | // rules that will be applied as soon as
240 | // is parsed for no-js pages.
241 | // Use `null` to disable any no-js fallback (not recommended).
242 | // Default: `
243 | // [data-input-otp] {
244 | // --nojs-bg: white !important;
245 | // --nojs-fg: black !important;
246 | //
247 | // background-color: var(--nojs-bg) !important;
248 | // color: var(--nojs-fg) !important;
249 | // caret-color: var(--nojs-fg) !important;
250 | // letter-spacing: .25em !important;
251 | // text-align: center !important;
252 | // border: 1px solid var(--nojs-fg) !important;
253 | // border-radius: 4px !important;
254 | // width: 100% !important;
255 | // }
256 | // @media (prefers-color-scheme: dark) {
257 | // [data-input-otp] {
258 | // --nojs-bg: black !important;
259 | // --nojs-fg: white !important;
260 | // }
261 | // }`
262 | noScriptCSSFallback?: string | null
263 | }
264 | ```
265 |
266 | ## Examples
267 |
268 |
269 | Automatic form submission on OTP completion
270 |
271 | ```tsx
272 | export default function Page() {
273 | const formRef = useRef(null)
274 | const buttonRef = useRef(null)
275 |
276 | return (
277 |
287 | )
288 | }
289 | ```
290 |
291 |
292 |
293 | Automatically focus the input when the page loads
294 |
295 | ```tsx
296 | export default function Page() {
297 | return (
298 |
304 | )
305 | }
306 | ```
307 |
308 |
309 | ## Caveats
310 |
311 |
312 | [Workaround] If you want to block specific password manager/badges:
313 |
314 | By default, `input-otp` handles password managers for you.
315 | The password manager badges should be automatically shifted to the right side.
316 |
317 | However, if you still want to block password managers, please disable the `pushPasswordManagerStrategy` and then manually block each PWM.
318 |
319 | ```diff
320 |
332 | ```
333 |
334 |
335 |
336 | [Setting] If you want to customize the `noscript` CSS fallback
337 |
338 | By default, `input-otp` handles cases where JS is not in the page by applying custom CSS styles.
339 | If you do not like the fallback design and want to apply it to your own, just pass a prop:
340 |
341 | ```diff
342 | // This is the default CSS fallback.
343 | // Feel free to change it entirely and apply to your design system.
344 | const NOSCRIPT_CSS_FALLBACK = `
345 | [data-input-otp] {
346 | --nojs-bg: white !important;
347 | --nojs-fg: black !important;
348 |
349 | background-color: var(--nojs-bg) !important;
350 | color: var(--nojs-fg) !important;
351 | caret-color: var(--nojs-fg) !important;
352 | letter-spacing: .25em !important;
353 | text-align: center !important;
354 | border: 1px solid var(--nojs-fg) !important;
355 | border-radius: 4px !important;
356 | width: 100% !important;
357 | }
358 | @media (prefers-color-scheme: dark) {
359 | [data-input-otp] {
360 | --nojs-bg: black !important;
361 | --nojs-fg: white !important;
362 | }
363 | }`
364 |
365 |
369 | ```
370 |
371 |
372 |
373 | [Workaround] If you're experiencing an unwanted border on input focus:
374 |
375 | ```diff
376 |
382 | ```
383 |
384 |
385 |
386 | [Not Recommended] If you want to centralize input text/selection, use the `textAlign` prop:
387 |
388 | ```diff
389 |
393 | ```
394 |
395 | NOTE: this also affects the selected caret position after a touch/click.
396 |
397 | `textAlign="left"`
398 |
399 |
400 |
401 | `textAlign="center"`
402 |
403 |
404 |
405 | `textAlign="right"`
406 |
407 |
408 |
409 |
410 |
411 |
412 | If you want to use Context props:
413 |
414 | ```diff
415 | +import { OTPInputContext } from 'input-otp'
416 |
417 | function MyForm() {
418 | return (
419 |
423 |
424 |
425 | )
426 | }
427 |
428 | +function OTPInputWrapper() {
429 | + const inputContext = React.useContext(OTPInputContext)
430 | + return (
431 | + <>
432 | + {inputContext.slots.map((slot, idx) => (
433 | +
434 | + ))}
435 | + >
436 | + )
437 | +}
438 | ```
439 |
440 | NOTE: this also affects the selected caret position after a touch/click.
441 |
442 | `textAlign="left"`
443 |
444 |
445 |
446 | `textAlign="center"`
447 |
448 |
449 |
450 | `textAlign="right"`
451 |
452 |
453 |
454 |
455 |
456 |
457 | [DX] Add Tailwind autocomplete for `containerClassname` attribute in VS Code.
458 |
459 | Add the following setting to your `.vscode/settings.json`:
460 | ```diff
461 | {
462 | "tailwindCSS.classAttributes": [
463 | "class",
464 | "className",
465 | + ".*ClassName"
466 | ]
467 | }
468 | ```
469 |
470 |
--------------------------------------------------------------------------------
/apps/storybook/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 | .eslintcache
21 |
22 | npm-debug.log*
23 | yarn-debug.log*
24 | yarn-error.log*
25 |
--------------------------------------------------------------------------------
/apps/storybook/.storybook/main.js:
--------------------------------------------------------------------------------
1 | /** @type { import('@storybook/react-vite').StorybookConfig } */
2 | const config = {
3 | stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
4 | staticDirs: ['../public'],
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 | docs: {
15 | autodocs: 'tag',
16 | },
17 | webpackFinal: async (config, { configType }) => {
18 | config.resolve.plugins = [new TsconfigPathsPlugin()]
19 | return config
20 | },
21 | }
22 | export default config
23 |
--------------------------------------------------------------------------------
/apps/storybook/.storybook/preview.js:
--------------------------------------------------------------------------------
1 | import '../src/globals.css'
2 |
3 | /** @type { import('@storybook/react').Preview } */
4 | const preview = {
5 | parameters: {
6 | actions: { argTypesRegex: "^on[A-Z].*" },
7 | controls: {
8 | matchers: {
9 | color: /(background|color)$/i,
10 | date: /Date$/,
11 | },
12 | },
13 | },
14 | };
15 |
16 | export default preview;
17 |
--------------------------------------------------------------------------------
/apps/storybook/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2018 Chroma Software Inc.
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 |
--------------------------------------------------------------------------------
/apps/storybook/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Chromatic's Intro to Storybook React template
9 |
10 |
11 | This template ships with the main React and Storybook configuration files you'll need to get up and running fast.
12 |
13 | ## 🚅 Quick start
14 |
15 | 1. **Create the application.**
16 |
17 | Use [degit](https://github.com/Rich-Harris/degit) to get this template.
18 |
19 | ```shell
20 | # Clone the template
21 | npx degit chromaui/intro-storybook-react-template taskbox
22 | ```
23 |
24 | 1. **Install the dependencies.**
25 |
26 | Navigate into your new site’s directory and install the necessary dependencies.
27 |
28 | ```shell
29 | # Navigate to the directory
30 | cd taskbox/
31 |
32 | # Install the dependencies
33 | yarn
34 | ```
35 |
36 | 1. **Open the source code and start editing!**
37 |
38 | Open the `taskbox` directory in your code editor of choice and building your first component!
39 |
40 | 1. **Browse your stories!**
41 |
42 | Run `yarn storybook` to see your component's stories at `http://localhost:6006`
43 |
44 | ## 🔎 What's inside?
45 |
46 | A quick look at the top-level files and directories included with this template.
47 |
48 | .
49 | ├── .storybook
50 | ├── node_modules
51 | ├── public
52 | ├── src
53 | ├── .gitignore
54 | ├── .index.html
55 | ├── LICENSE
56 | ├── package.json
57 | ├── yarn.lock
58 | ├── vite.config.js
59 | └── README.md
60 |
61 | 1. **`.storybook`**: This directory contains Storybook's [configuration](https://storybook.js.org/docs/react/configure/overview) files.
62 |
63 | 2. **`node_modules`**: This directory contains all of the modules of code that your project depends on (npm packages).
64 |
65 | 3. **`public`**: This directory will contain the development and production build of the site.
66 |
67 | 4. **`src`**: This directory will contain all of the code related to what you will see on your application.
68 |
69 | 5. **`.gitignore`**: This file tells git which files it should not track or maintain during the development process of your project.
70 |
71 | 6. **`.index.html`**: This is the HTML page that is served when generating a development or production build.
72 |
73 | 7. **`LICENSE`**: The template is licensed under the MIT licence.
74 |
75 | 8. **`package.json`**: Standard manifest file for Node.js projects, which typically includes project specific metadata (such as the project's name, the author among other information). It's based on this file that npm will know which packages are necessary to the project.
76 |
77 | 9. **`yarn.lock`**: This is an automatically generated file based on the exact versions of your npm dependencies that were installed for your project. **(Do not change it manually).**
78 |
79 | 10. **`vite.config.js`**: This is the configuration file for [Vite](https://vitejs.dev/), a build tool that aims to provide a faster and leaner development experience for modern web projects.
80 |
81 | 11. **`README.md`**: A text file containing useful reference information about the project.
82 |
83 | ## Contribute
84 |
85 | If you encounter an issue with the template, we encourage you to open an issue in this template's repository.
86 |
87 | ## Learning Storybook
88 |
89 | 1. Read our introductory tutorial at [Learn Storybook](https://storybook.js.org/tutorials/intro-to-storybook/react/en/get-started/).
90 | 2. Learn how to transform your component libraries into design systems in our [Design Systems for Developers](https://storybook.js.org/tutorials/design-systems-for-developers/) tutorial.
91 | 3. See our official documentation at [Storybook](https://storybook.js.org/).
92 |
--------------------------------------------------------------------------------
/apps/storybook/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "src/globals.css",
9 | "baseColor": "stone",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
--------------------------------------------------------------------------------
/apps/storybook/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite + React
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/apps/storybook/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "storybook",
3 | "private": true,
4 | "version": "0.2.0",
5 | "type": "module",
6 | "repository": {
7 | "type": "git",
8 | "url": "https://github.com/chromaui/intro-storybook-react-template"
9 | },
10 | "bugs": {
11 | "url": "https://github.com/chromaui/intro-storybook-react-template/issues"
12 | },
13 | "license": "MIT",
14 | "dependencies": {
15 | "@radix-ui/react-icons": "^1.3.0",
16 | "class-variance-authority": "^0.7.0",
17 | "clsx": "^2.1.0",
18 | "input-otp": "workspace:*",
19 | "react": "^18.2.0",
20 | "react-dom": "^18.2.0",
21 | "tailwind-merge": "^2.2.1",
22 | "tailwindcss-animate": "^1.0.7"
23 | },
24 | "scripts": {
25 | "storybook": "storybook dev -p 6006",
26 | "build-storybook": "storybook build",
27 | "init-msw": "msw init public/"
28 | },
29 | "devDependencies": {
30 | "@storybook/addon-essentials": "^7.6.6",
31 | "@storybook/addon-interactions": "^7.6.6",
32 | "@storybook/addon-links": "^7.6.6",
33 | "@storybook/blocks": "^7.6.6",
34 | "@storybook/react": "^7.6.6",
35 | "@storybook/react-vite": "^7.6.6",
36 | "@storybook/test": "^7.6.6",
37 | "@types/react": "^18.0.28",
38 | "@types/react-dom": "^18.0.11",
39 | "@vitejs/plugin-react": "^3.1.0",
40 | "autoprefixer": "^10.0.1",
41 | "msw": "^1.2.1",
42 | "msw-storybook-addon": "^1.10.0",
43 | "postcss": "^8",
44 | "prop-types": "^15.8.1",
45 | "storybook": "^7.6.6",
46 | "tailwindcss": "^3.4.1",
47 | "tsconfig-paths-webpack-plugin": "^4.1.0",
48 | "typescript": "^5.4.2",
49 | "vite": "^4.2.0"
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/apps/storybook/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/storybook/src/assets/font/OpenSans-Light-webfont.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shadcn/input-otp/6d74289761f3c24fbb2ab907aff481e1038b7e06/apps/storybook/src/assets/font/OpenSans-Light-webfont.eot
--------------------------------------------------------------------------------
/apps/storybook/src/assets/font/OpenSans-Light-webfont.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shadcn/input-otp/6d74289761f3c24fbb2ab907aff481e1038b7e06/apps/storybook/src/assets/font/OpenSans-Light-webfont.ttf
--------------------------------------------------------------------------------
/apps/storybook/src/assets/font/OpenSans-Light-webfont.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shadcn/input-otp/6d74289761f3c24fbb2ab907aff481e1038b7e06/apps/storybook/src/assets/font/OpenSans-Light-webfont.woff
--------------------------------------------------------------------------------
/apps/storybook/src/assets/font/OpenSans-Regular-webfont.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shadcn/input-otp/6d74289761f3c24fbb2ab907aff481e1038b7e06/apps/storybook/src/assets/font/OpenSans-Regular-webfont.eot
--------------------------------------------------------------------------------
/apps/storybook/src/assets/font/OpenSans-Regular-webfont.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shadcn/input-otp/6d74289761f3c24fbb2ab907aff481e1038b7e06/apps/storybook/src/assets/font/OpenSans-Regular-webfont.ttf
--------------------------------------------------------------------------------
/apps/storybook/src/assets/font/OpenSans-Regular-webfont.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shadcn/input-otp/6d74289761f3c24fbb2ab907aff481e1038b7e06/apps/storybook/src/assets/font/OpenSans-Regular-webfont.woff
--------------------------------------------------------------------------------
/apps/storybook/src/assets/icon/percolate.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shadcn/input-otp/6d74289761f3c24fbb2ab907aff481e1038b7e06/apps/storybook/src/assets/icon/percolate.eot
--------------------------------------------------------------------------------
/apps/storybook/src/assets/icon/percolate.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shadcn/input-otp/6d74289761f3c24fbb2ab907aff481e1038b7e06/apps/storybook/src/assets/icon/percolate.ttf
--------------------------------------------------------------------------------
/apps/storybook/src/assets/icon/percolate.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shadcn/input-otp/6d74289761f3c24fbb2ab907aff481e1038b7e06/apps/storybook/src/assets/icon/percolate.woff
--------------------------------------------------------------------------------
/apps/storybook/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/storybook/src/components/ui/input-otp.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { DashIcon } from "@radix-ui/react-icons"
5 | import { OTPInput, SlotProps } from "input-otp"
6 |
7 | import { cn } from "../../lib/utils"
8 |
9 | const InputOTP = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => (
13 |
18 | ))
19 | InputOTP.displayName = "InputOTP"
20 |
21 | const InputOTPGroup = React.forwardRef<
22 | React.ElementRef<"div">,
23 | React.ComponentPropsWithoutRef<"div">
24 | >(({ className, ...props }, ref) => (
25 |
26 | ))
27 | InputOTPGroup.displayName = "InputOTPGroup"
28 |
29 | const InputOTPSlot = React.forwardRef<
30 | React.ElementRef<"div">,
31 | SlotProps & React.ComponentPropsWithoutRef<"div">
32 | >(({ char, hasFakeCaret, isActive, className, ...props }, ref) => {
33 | return (
34 |
43 | {char}
44 | {hasFakeCaret && (
45 |
48 | )}
49 |
50 | )
51 | })
52 | InputOTPSlot.displayName = "InputOTPSlot"
53 |
54 | const InputOTPSeparator = React.forwardRef<
55 | React.ElementRef<"div">,
56 | React.ComponentPropsWithoutRef<"div">
57 | >(({ ...props }, ref) => (
58 |
59 |
60 |
61 | ))
62 | InputOTPSeparator.displayName = "InputOTPSeparator"
63 |
64 | export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
65 |
--------------------------------------------------------------------------------
/apps/storybook/src/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 240 10% 3.9%;
9 | --card: 0 0% 100%;
10 | --card-foreground: 240 10% 3.9%;
11 | --popover: 0 0% 100%;
12 | --popover-foreground: 240 10% 3.9%;
13 | --primary: 240 5.9% 10%;
14 | --primary-foreground: 0 0% 98%;
15 | --secondary: 240 4.8% 95.9%;
16 | --secondary-foreground: 240 5.9% 10%;
17 | --muted: 240 4.8% 95.9%;
18 | --muted-foreground: 240 3.8% 46.1%;
19 | --accent: 240 4.8% 95.9%;
20 | --accent-foreground: 240 5.9% 10%;
21 | --destructive: 0 72.22% 50.59%;
22 | --destructive-foreground: 0 0% 98%;
23 | --border: 240 5.9% 90%;
24 | --input: 240 5.9% 90%;
25 | --ring: 240 5% 64.9%;
26 | --radius: 0.5rem;
27 | }
28 |
29 | .dark {
30 | --background: 240 10% 3.9%;
31 | --foreground: 0 0% 98%;
32 | --card: 240 10% 3.9%;
33 | --card-foreground: 0 0% 98%;
34 | --popover: 240 10% 3.9%;
35 | --popover-foreground: 0 0% 98%;
36 | --primary: 0 0% 98%;
37 | --primary-foreground: 240 5.9% 10%;
38 | --secondary: 240 3.7% 15.9%;
39 | --secondary-foreground: 0 0% 98%;
40 | --muted: 240 3.7% 15.9%;
41 | --muted-foreground: 240 5% 64.9%;
42 | --accent: 240 3.7% 15.9%;
43 | --accent-foreground: 0 0% 98%;
44 | --destructive: 0 62.8% 30.6%;
45 | --destructive-foreground: 0 85.7% 97.3%;
46 | --border: 240 3.7% 15.9%;
47 | --input: 240 3.7% 15.9%;
48 | --ring: 240 4.9% 83.9%;
49 | }
50 | }
51 |
52 | @layer base {
53 | * {
54 | @apply border-border;
55 | }
56 | body {
57 | @apply bg-background text-foreground;
58 | /* font-feature-settings: "rlig" 1, "calt" 1; */
59 | font-synthesis-weight: none;
60 | text-rendering: optimizeLegibility;
61 | }
62 | }
63 |
64 | @layer utilities {
65 | }
66 |
--------------------------------------------------------------------------------
/apps/storybook/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/apps/storybook/src/stories/Button.stories.ts:
--------------------------------------------------------------------------------
1 | import { Button } from './Button';
2 |
3 | // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction
4 | export default {
5 | title: 'Example/Button',
6 | component: Button,
7 | tags: ['autodocs'],
8 | argTypes: {
9 | backgroundColor: { control: 'color' },
10 | },
11 | };
12 |
13 | // More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args
14 | export const Primary = {
15 | args: {
16 | primary: true,
17 | label: 'Button',
18 | },
19 | };
20 |
21 | export const Secondary = {
22 | args: {
23 | label: 'Button',
24 | },
25 | };
26 |
27 | export const Large = {
28 | args: {
29 | size: 'large',
30 | label: 'Button',
31 | },
32 | };
33 |
34 | export const Small = {
35 | args: {
36 | size: 'small',
37 | label: 'Button',
38 | },
39 | };
40 |
--------------------------------------------------------------------------------
/apps/storybook/src/stories/Button.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import './button.css';
4 |
5 | /**
6 | * Primary UI component for user interaction
7 | */
8 | export const Button = ({ primary, backgroundColor, size, label, ...props }) => {
9 | const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary';
10 | return (
11 |
17 | {label}
18 |
19 | );
20 | };
21 |
22 | Button.propTypes = {
23 | /**
24 | * Is this the principal call to action on the page?
25 | */
26 | primary: PropTypes.bool,
27 | /**
28 | * What background color to use
29 | */
30 | backgroundColor: PropTypes.string,
31 | /**
32 | * How large should the button be?
33 | */
34 | size: PropTypes.oneOf(['small', 'medium', 'large']),
35 | /**
36 | * Button contents
37 | */
38 | label: PropTypes.string.isRequired,
39 | /**
40 | * Optional click handler
41 | */
42 | onClick: PropTypes.func,
43 | };
44 |
45 | Button.defaultProps = {
46 | backgroundColor: null,
47 | primary: false,
48 | size: 'medium',
49 | onClick: undefined,
50 | };
51 |
--------------------------------------------------------------------------------
/apps/storybook/src/stories/InputOTP.stories.tsx:
--------------------------------------------------------------------------------
1 | import { InputOTP, InputOTPGroup, InputOTPSlot } from "../components/ui/input-otp"
2 | import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp"
3 |
4 | const meta = {
5 | title: "ui/InputOTP",
6 | component: InputOTP,
7 | tags: ["autodocs"],
8 | argTypes: {},
9 | args: {
10 | maxLength: 6,
11 | pattern: REGEXP_ONLY_DIGITS_AND_CHARS,
12 | render: ({ slots }) => (
13 |
14 | {slots.map((slot, index) => (
15 |
16 | ))}
17 |
18 | ),
19 | },
20 | parameters: {
21 | layout: "centered",
22 | },
23 | }
24 |
25 | export default meta
26 |
27 | type Story = StoryObj
28 |
29 | export const Default: Story = {}
--------------------------------------------------------------------------------
/apps/storybook/src/stories/Introduction.mdx:
--------------------------------------------------------------------------------
1 | import { Meta } from '@storybook/blocks';
2 | import Code from './assets/code-brackets.svg';
3 | import Colors from './assets/colors.svg';
4 | import Comments from './assets/comments.svg';
5 | import Direction from './assets/direction.svg';
6 | import Flow from './assets/flow.svg';
7 | import Plugin from './assets/plugin.svg';
8 | import Repo from './assets/repo.svg';
9 | import StackAlt from './assets/stackalt.svg';
10 |
11 |
12 |
13 |
118 |
119 | # Welcome to Storybook
120 |
121 | Storybook helps you build UI components in isolation from your app's business logic, data, and context.
122 | That makes it easy to develop hard-to-reach states. Save these UI states as **stories** to revisit during development, testing, or QA.
123 |
124 | Browse example stories now by navigating to them in the sidebar.
125 | View their code in the `stories` directory to learn how they work.
126 | We recommend building UIs with a [**component-driven**](https://componentdriven.org) process starting with atomic components and ending with pages.
127 |
128 | Configure
129 |
130 |
176 |
177 | Learn
178 |
179 |
209 |
210 |
211 | Tip Edit the Markdown in{' '}
212 | stories/Introduction.stories.mdx
213 |
214 |
--------------------------------------------------------------------------------
/apps/storybook/src/stories/assets/code-brackets.svg:
--------------------------------------------------------------------------------
1 | illustration/code-brackets
--------------------------------------------------------------------------------
/apps/storybook/src/stories/assets/colors.svg:
--------------------------------------------------------------------------------
1 | illustration/colors
--------------------------------------------------------------------------------
/apps/storybook/src/stories/assets/comments.svg:
--------------------------------------------------------------------------------
1 | illustration/comments
--------------------------------------------------------------------------------
/apps/storybook/src/stories/assets/direction.svg:
--------------------------------------------------------------------------------
1 | illustration/direction
--------------------------------------------------------------------------------
/apps/storybook/src/stories/assets/flow.svg:
--------------------------------------------------------------------------------
1 | illustration/flow
--------------------------------------------------------------------------------
/apps/storybook/src/stories/assets/plugin.svg:
--------------------------------------------------------------------------------
1 | illustration/plugin
--------------------------------------------------------------------------------
/apps/storybook/src/stories/assets/repo.svg:
--------------------------------------------------------------------------------
1 | illustration/repo
--------------------------------------------------------------------------------
/apps/storybook/src/stories/assets/stackalt.svg:
--------------------------------------------------------------------------------
1 | illustration/stackalt
--------------------------------------------------------------------------------
/apps/storybook/src/stories/button.css:
--------------------------------------------------------------------------------
1 | .storybook-button {
2 | font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
3 | font-weight: 700;
4 | border: 0;
5 | border-radius: 3em;
6 | cursor: pointer;
7 | display: inline-block;
8 | line-height: 1;
9 | }
10 | .storybook-button--primary {
11 | color: white;
12 | background-color: #1ea7fd;
13 | }
14 | .storybook-button--secondary {
15 | color: #333;
16 | background-color: transparent;
17 | box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset;
18 | }
19 | .storybook-button--small {
20 | font-size: 12px;
21 | padding: 10px 16px;
22 | }
23 | .storybook-button--medium {
24 | font-size: 14px;
25 | padding: 11px 20px;
26 | }
27 | .storybook-button--large {
28 | font-size: 16px;
29 | padding: 12px 24px;
30 | }
31 |
--------------------------------------------------------------------------------
/apps/storybook/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | const config = {
3 | darkMode: ['class'],
4 | content: [
5 | './index.html',
6 | './src/stories/**/*.{ts,tsx}',
7 | './src/**/*.{ts,tsx}',
8 | '.storybook/**/*.{js,ts,tsx}',
9 | ],
10 | prefix: '',
11 | theme: {
12 | container: {
13 | center: true,
14 | padding: '2rem',
15 | screens: {
16 | '2xl': '1400px',
17 | },
18 | },
19 | extend: {
20 | transitionDelay: {
21 | 1500: '1500ms',
22 | },
23 | colors: {
24 | border: 'hsl(var(--border))',
25 | input: 'hsl(var(--input))',
26 | ring: 'hsl(var(--ring))',
27 | background: 'hsl(var(--background))',
28 | foreground: 'hsl(var(--foreground))',
29 | primary: {
30 | DEFAULT: 'hsl(var(--primary))',
31 | foreground: 'hsl(var(--primary-foreground))',
32 | },
33 | secondary: {
34 | DEFAULT: 'hsl(var(--secondary))',
35 | foreground: 'hsl(var(--secondary-foreground))',
36 | },
37 | destructive: {
38 | DEFAULT: 'hsl(var(--destructive))',
39 | foreground: 'hsl(var(--destructive-foreground))',
40 | },
41 | muted: {
42 | DEFAULT: 'hsl(var(--muted))',
43 | foreground: 'hsl(var(--muted-foreground))',
44 | },
45 | accent: {
46 | DEFAULT: 'hsl(var(--accent))',
47 | foreground: 'hsl(var(--accent-foreground))',
48 | },
49 | popover: {
50 | DEFAULT: 'hsl(var(--popover))',
51 | foreground: 'hsl(var(--popover-foreground))',
52 | },
53 | card: {
54 | DEFAULT: 'hsl(var(--card))',
55 | foreground: 'hsl(var(--card-foreground))',
56 | },
57 | },
58 | borderRadius: {
59 | lg: 'var(--radius)',
60 | md: 'calc(var(--radius) - 2px)',
61 | sm: 'calc(var(--radius) - 4px)',
62 | },
63 | keyframes: {
64 | 'accordion-down': {
65 | from: { height: '0' },
66 | to: { height: 'var(--radix-accordion-content-height)' },
67 | },
68 | 'accordion-up': {
69 | from: { height: 'var(--radix-accordion-content-height)' },
70 | to: { height: '0' },
71 | },
72 |
73 | 'caret-blink': {
74 | '0%,70%,100%': {
75 | opacity: '1',
76 | },
77 | '20%,50%': {
78 | opacity: '0',
79 | },
80 | },
81 |
82 | 'fade-in': {
83 | from: {
84 | opacity: '0',
85 | },
86 | to: { opacity: '1' },
87 | },
88 | 'fade-up': {
89 | from: {
90 | opacity: '0',
91 | transform: 'translateY(var(--fade-distance, .25rem))',
92 | },
93 | to: { opacity: '1', transform: 'translateY(0)' },
94 | },
95 | },
96 | animation: {
97 | 'accordion-down': 'accordion-down 0.2s ease-out',
98 | 'accordion-up': 'accordion-up 0.2s ease-out',
99 |
100 | 'caret-blink': 'caret-blink 1.2s ease-out infinite',
101 | 'fade-in': 'fade-in 0.3s ease-out forwards',
102 | 'fade-up': 'fade-up 1s ease-out forwards',
103 | },
104 | },
105 | },
106 | // plugins: [require('tailwindcss-animate')],
107 | }
108 |
109 | module.exports = config
--------------------------------------------------------------------------------
/apps/storybook/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
6 | "allowJs": false,
7 | "skipLibCheck": true,
8 | "esModuleInterop": false,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "module": "ESNext",
13 | "moduleResolution": "Node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx",
18 | "paths": {
19 | "@/*": ["./src/*"]
20 | }
21 | },
22 | "include": ["src"],
23 | "references": [{ "path": "./tsconfig.node.json" }]
24 | }
25 |
--------------------------------------------------------------------------------
/apps/storybook/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "module": "ESNext",
5 | "moduleResolution": "Node",
6 | "allowSyntheticDefaultImports": true
7 | },
8 | "include": ["vite.config.ts"]
9 | }
--------------------------------------------------------------------------------
/apps/storybook/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | })
8 |
--------------------------------------------------------------------------------
/apps/test/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals",
3 | "rules": {
4 | "@typescript-eslint/no-unused-vars": "warn"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/apps/test/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
--------------------------------------------------------------------------------
/apps/test/README.md:
--------------------------------------------------------------------------------
1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
2 |
3 | ## Getting Started
4 |
5 | First, run the development server:
6 |
7 | ```bash
8 | npm run dev
9 | # or
10 | yarn dev
11 | # or
12 | pnpm dev
13 | # or
14 | bun dev
15 | ```
16 |
17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
18 |
19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
20 |
21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
22 |
23 | ## Learn More
24 |
25 | To learn more about Next.js, take a look at the following resources:
26 |
27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
29 |
30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
31 |
32 | ## Deploy on Vercel
33 |
34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
35 |
36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
37 |
--------------------------------------------------------------------------------
/apps/test/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {};
3 |
4 | export default nextConfig;
5 |
--------------------------------------------------------------------------------
/apps/test/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "test",
3 | "private": true,
4 | "scripts": {
5 | "dev": "next dev --port=3039",
6 | "lint": "next lint",
7 | "test": "playwright test",
8 | "test:ui": "playwright test --ui"
9 | },
10 | "dependencies": {
11 | "clsx": "^2.1.0",
12 | "next": "14.1.0",
13 | "react": "^18",
14 | "react-dom": "^18",
15 | "tailwind-merge": "^2.2.1",
16 | "input-otp": "workspace:*"
17 | },
18 | "devDependencies": {
19 | "@playwright/test": "^1.41.2",
20 | "@types/node": "^20",
21 | "@types/react": "^18",
22 | "@types/react-dom": "^18",
23 | "autoprefixer": "^10.0.1",
24 | "eslint": "^8",
25 | "eslint-config-next": "14.1.0",
26 | "postcss": "^8",
27 | "tailwindcss": "^3.3.0",
28 | "typescript": "^5"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/apps/test/playwright.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, devices } from '@playwright/test'
2 |
3 | /**
4 | * Read environment variables from file.
5 | * https://github.com/motdotla/dotenv
6 | */
7 | // require('dotenv').config();
8 |
9 | /**
10 | * See https://playwright.dev/docs/test-configuration.
11 | */
12 | export default defineConfig({
13 | testDir: '.',
14 | /* Maximum time one test can run for. */
15 | timeout: 30 * 1000,
16 | expect: {
17 | /**
18 | * Maximum time expect() should wait for the condition to be met.
19 | * For example in `await expect(locator).toHaveText();`
20 | */
21 | timeout: 5000,
22 | },
23 | /* Run tests in files in parallel */
24 | fullyParallel: process.env.WINDOWED_TESTS ? false : true,
25 | /* Fail the build on CI if you accidentally left test.only in the source code. */
26 | forbidOnly: !!process.env.CI,
27 | retries: 2,
28 | /* Opt out of parallel tests on CI. */
29 | workers: process.env.CI ? 1 : undefined,
30 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */
31 | reporter: 'html',
32 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
33 | use: {
34 | trace: 'on-first-retry',
35 | baseURL: 'http://localhost:3039',
36 | headless: process.env.WINDOWED_TESTS ? false : true,
37 | launchOptions: {
38 | slowMo: process.env.WINDOWED_TESTS ? 500 : 0,
39 | },
40 | },
41 | webServer: {
42 | command: 'npm run dev',
43 | url: 'http://localhost:3039',
44 | cwd: '.',
45 | reuseExistingServer: !process.env.CI,
46 | },
47 | /* Configure projects for major browsers */
48 | projects: [
49 | {
50 | name: 'chromium',
51 | use: {
52 | ...devices['Desktop Chrome'],
53 | },
54 | },
55 | {
56 | name: 'firefox',
57 | use: {
58 | ...devices['Desktop Firefox'],
59 | },
60 | },
61 | {
62 | name: 'webkit',
63 | use: { ...devices['Desktop Safari'], contextOptions: {} },
64 | },
65 |
66 | /* Test against mobile viewports. */
67 | {
68 | name: 'Mobile Chrome',
69 | use: { ...devices['Pixel 5'] },
70 | },
71 | {
72 | name: 'Mobile Safari',
73 | use: { ...devices['iPhone 12'] },
74 | },
75 |
76 | /* Test against branded browsers. */
77 | {
78 | name: 'Microsoft Edge',
79 | use: { ...devices['Desktop Edge'], channel: 'msedge' },
80 | },
81 | {
82 | name: 'Google Chrome',
83 | use: { ...devices['Desktop Chrome'], channel: 'chrome' },
84 | },
85 | ],
86 | })
87 |
--------------------------------------------------------------------------------
/apps/test/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/apps/test/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/test/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/test/src/app/base/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 |
5 | import { BaseOTPInput } from '@/components/base-input'
6 |
7 | export default function Page() {
8 | return (
9 |
10 |
11 |
12 | {/* Editable for testing copy-pasting into input */}
13 |
14 |
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/apps/test/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shadcn/input-otp/6d74289761f3c24fbb2ab907aff481e1038b7e06/apps/test/src/app/favicon.ico
--------------------------------------------------------------------------------
/apps/test/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | --foreground-rgb: 0, 0, 0;
7 | --background-start-rgb: 214, 219, 220;
8 | --background-end-rgb: 255, 255, 255;
9 | }
10 |
11 | @media (prefers-color-scheme: dark) {
12 | :root {
13 | --foreground-rgb: 255, 255, 255;
14 | --background-start-rgb: 0, 0, 0;
15 | --background-end-rgb: 0, 0, 0;
16 | }
17 | }
18 |
19 | body {
20 | color: rgb(var(--foreground-rgb));
21 | background: linear-gradient(
22 | to bottom,
23 | transparent,
24 | rgb(var(--background-end-rgb))
25 | )
26 | rgb(var(--background-start-rgb));
27 | }
28 |
29 | @layer utilities {
30 | .text-balance {
31 | text-wrap: balance;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/apps/test/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next'
2 | import { Inter } from 'next/font/google'
3 | import './globals.css'
4 |
5 | const inter = Inter({ subsets: ['latin'] })
6 |
7 | export const metadata: Metadata = {
8 | title: 'Create Next App',
9 | description: 'Generated by create next app',
10 | }
11 |
12 | export default function RootLayout({
13 | children,
14 | }: Readonly<{
15 | children: React.ReactNode
16 | }>) {
17 | return (
18 |
19 |
25 | {children}
26 |
27 |
28 | )
29 | }
30 |
--------------------------------------------------------------------------------
/apps/test/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link'
2 |
3 | export default function Home() {
4 | return (
5 |
6 | Base behavior
7 |
8 | )
9 | }
10 |
--------------------------------------------------------------------------------
/apps/test/src/app/props/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 |
5 | import { BaseOTPInput } from '@/components/base-input'
6 |
7 | export default function Page() {
8 | return (
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/apps/test/src/app/with-autofocus/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 |
5 | import { BaseOTPInput } from '@/components/base-input'
6 |
7 | export default function Page() {
8 | return (
9 |
10 |
11 |
12 | )
13 | }
14 |
--------------------------------------------------------------------------------
/apps/test/src/app/with-on-complete/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 |
5 | import { BaseOTPInput } from '@/components/base-input'
6 |
7 | export default function Page() {
8 | const [disabled, setDisabled] = React.useState(false)
9 |
10 | return (
11 |
12 | setDisabled(true)} disabled={disabled} />
13 |
14 | )
15 | }
16 |
--------------------------------------------------------------------------------
/apps/test/src/components/base-input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import { OTPInput } from 'input-otp'
4 | import { cn } from '@/lib/utils'
5 |
6 | export function BaseOTPInput(
7 | overrideProps: Partial> = {},
8 | ) {
9 | const [value, setValue] = React.useState('')
10 | const [disabled, setDisabled] = React.useState(false)
11 |
12 | return (
13 | (
23 |
31 | {slots.map((slot, idx) => (
32 |
44 | {slot.char !== null ? slot.char : ' '}
45 |
46 | ))}
47 |
48 | )}
49 | {...overrideProps}
50 | />
51 | )
52 | }
53 |
--------------------------------------------------------------------------------
/apps/test/src/lib/fonts.ts:
--------------------------------------------------------------------------------
1 | // import { JetBrains_Mono as FontMono, Inter as FontSans } from "next/font/google"
2 | import { JetBrains_Mono as FontMono } from 'next/font/google'
3 | // import { GeistMono } from "geist/font/mono"
4 | import { GeistSans } from 'geist/font/sans'
5 |
6 | // export const fontSans = FontSans({
7 | // subsets: ["latin"],
8 | // variable: "--font-sans",
9 | // })
10 | export const fontSans = GeistSans
11 |
12 | export const fontMono = FontMono({
13 | subsets: ['latin'],
14 | variable: '--font-mono',
15 | })
16 |
--------------------------------------------------------------------------------
/apps/test/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/apps/test/src/tests/base.delete-word.spec.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from '@playwright/test'
2 | import { modifier } from './util/modifier'
3 |
4 | test.beforeEach(async ({ page }) => {
5 | await page.goto('/base')
6 | })
7 |
8 | test.describe('Delete words', () => {
9 | test('should backspace previous word (even if there is not a selected character)', async ({ page }) => {
10 | const input = page.getByRole('textbox')
11 |
12 | await input.pressSequentially('1234')
13 | await expect(input).toHaveValue('1234')
14 |
15 | await input.press(`${modifier}+Backspace`)
16 | await expect(input).toHaveValue('')
17 | })
18 | test('should backspace selected char', async ({ page }) => {
19 | const input = page.getByRole('textbox')
20 |
21 | await input.pressSequentially('123456')
22 | await expect(input).toHaveValue('123456')
23 |
24 | await input.press('ArrowLeft')
25 | await input.press('ArrowLeft')
26 | await input.press(`${modifier}+Backspace`)
27 |
28 | await expect(input).toHaveValue('12356')
29 | })
30 | test('should forward-delete character when pressing delete', async ({ page }) => {
31 | const input = page.getByRole('textbox')
32 |
33 | await input.pressSequentially('123456')
34 | await expect(input).toHaveValue('123456')
35 |
36 | await input.press('Delete')
37 | await expect(input).toHaveValue('12345')
38 | await input.press('ArrowLeft')
39 | await input.press('ArrowLeft')
40 | await input.press('ArrowLeft')
41 | await input.press('ArrowLeft')
42 | await input.press('ArrowLeft')
43 | await input.press('Delete')
44 | await expect(input).toHaveValue('2345')
45 | await input.press('ArrowRight')
46 | await input.press('ArrowRight')
47 | await input.press('Delete')
48 | await expect(input).toHaveValue('235')
49 | })
50 | })
51 |
--------------------------------------------------------------------------------
/apps/test/src/tests/base.props.spec.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from '@playwright/test'
2 |
3 | test.beforeEach(async ({ page }) => {
4 | await page.goto('/props')
5 | })
6 |
7 | test.describe('Props tests', () => {
8 | test('should receive props accordingly', async ({ page }) => {
9 | const input1 = page.getByTestId('input-otp-1')
10 | const input2 = page.getByTestId('input-otp-2')
11 | const input3 = page.getByTestId('input-otp-3')
12 | const container4 = page.locator(
13 | `[data-input-otp-container]:has([data-testid="input-otp-4"])`,
14 | )
15 | const input5 = page.getByTestId('input-otp-5')
16 | const input6 = page.getByTestId('input-otp-6')
17 | const input7 = page.getByTestId('input-otp-7')
18 |
19 | await expect(input1).toBeDisabled()
20 |
21 | await expect(input2).toHaveAttribute('inputmode', 'numeric')
22 |
23 | await expect(input3).toHaveAttribute('inputmode', 'text')
24 |
25 | await expect(container4).toHaveClass('testclassname')
26 |
27 | await expect(input5).toHaveAttribute('maxLength', '3')
28 |
29 | await expect(input6).toHaveAttribute('id', 'testid')
30 | await expect(input6).toHaveAttribute('name', 'testname')
31 |
32 | await expect(input7).toHaveAttribute('pattern', ' ')
33 | })
34 | })
35 |
--------------------------------------------------------------------------------
/apps/test/src/tests/base.render.spec.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from '@playwright/test'
2 |
3 | const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'
4 |
5 | test.beforeEach(async ({ page }) => {
6 | await page.goto('/base')
7 | })
8 |
9 | test.describe('Base tests - Render', () => {
10 | test('should expose focus flags', async ({ page }) => {
11 | const input = page.getByRole('textbox')
12 | const renderer = page.getByTestId('input-otp-renderer')
13 |
14 | await input.focus()
15 | await expect(renderer).toHaveAttribute('data-test-render-is-focused', 'true')
16 |
17 | await input.blur()
18 | await page.waitForTimeout(100)
19 | await expect(renderer).not.toHaveAttribute('data-test-render-is-focused')
20 | })
21 | test('should expose hover flags', async ({ page }) => {
22 | const renderer = page.getByTestId('input-otp-renderer')
23 |
24 | await expect(renderer).not.toHaveAttribute('data-test-render-is-hovering')
25 |
26 | const _rect = await renderer.boundingBox({ timeout: 2_000 })
27 | expect(_rect).not.toBeNull()
28 | const rect = _rect!
29 | await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2)
30 |
31 | await expect(renderer).toHaveAttribute('data-test-render-is-hovering', 'true')
32 | })
33 | })
34 |
--------------------------------------------------------------------------------
/apps/test/src/tests/base.selections.spec.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from '@playwright/test'
2 |
3 | test.beforeEach(async ({ page }) => {
4 | await page.goto('/base')
5 | })
6 |
7 | test.describe('Base tests - Selections', () => {
8 | test.skip(
9 | process.env.CI === 'true',
10 | 'Breaks in CI as it cannot handle Shift key',
11 | )
12 |
13 | test('should replace selected char if another is pressed', async ({
14 | page,
15 | }) => {
16 | const input = page.getByRole('textbox')
17 |
18 | await input.pressSequentially('123')
19 | // arrow left on keyboard
20 | await input.press('ArrowLeft')
21 | await input.pressSequentially('1')
22 | await expect(input).toHaveValue('121')
23 | })
24 | test('should replace multi-selected chars if another is pressed', async ({
25 | page,
26 | }) => {
27 | const input = page.getByRole('textbox')
28 |
29 | await input.pressSequentially('123456')
30 | await page.waitForTimeout(100)
31 | await input.press('Shift+ArrowLeft')
32 | await input.press('Shift+ArrowLeft')
33 | await page.waitForTimeout(100)
34 | await input.pressSequentially('1')
35 | await expect(input).toHaveValue('1231')
36 | })
37 | test('should replace last char if another one is pressed', async ({
38 | page,
39 | }) => {
40 | const input = page.getByRole('textbox')
41 |
42 | await input.pressSequentially('1234567')
43 | await page.waitForTimeout(100)
44 |
45 | await expect(input).toHaveValue('123457')
46 | })
47 | })
48 |
--------------------------------------------------------------------------------
/apps/test/src/tests/base.slot.spec.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from '@playwright/test'
2 |
3 | const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'
4 |
5 | test.beforeEach(async ({ page }) => {
6 | await page.goto('/base')
7 | })
8 |
9 | test.describe('Base tests - Slots', () => {
10 | test('should expose the slot value', async ({ page }) => {
11 | const input = page.getByRole('textbox')
12 |
13 | await input.pressSequentially('1')
14 | await expect(input).toHaveValue('1')
15 |
16 | const slot0 = page.getByTestId('slot-0')
17 | await expect(slot0).toHaveAttribute('data-test-char', '1')
18 |
19 | await expect(page.getByTestId('slot-1')).not.toHaveAttribute(
20 | 'data-test-char',
21 | )
22 |
23 | await input.pressSequentially('23456')
24 | await expect(input).toHaveValue('123456')
25 |
26 | await expect(page.getByTestId('slot-1')).toHaveAttribute(
27 | 'data-test-char',
28 | '2',
29 | )
30 | await expect(page.getByTestId('slot-2')).toHaveAttribute(
31 | 'data-test-char',
32 | '3',
33 | )
34 | await expect(page.getByTestId('slot-3')).toHaveAttribute(
35 | 'data-test-char',
36 | '4',
37 | )
38 | await expect(page.getByTestId('slot-4')).toHaveAttribute(
39 | 'data-test-char',
40 | '5',
41 | )
42 | await expect(page.getByTestId('slot-5')).toHaveAttribute(
43 | 'data-test-char',
44 | '6',
45 | )
46 | })
47 | })
48 |
--------------------------------------------------------------------------------
/apps/test/src/tests/base.typing.spec.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from '@playwright/test'
2 | import { modifier } from './util/modifier'
3 |
4 | test.beforeEach(async ({ page }) => {
5 | await page.goto('/base')
6 | })
7 |
8 | test.describe('Base tests - Typing', () => {
9 | test('should start as empty value', async ({ page }) => {
10 | const input = page.getByRole('textbox')
11 |
12 | await expect(input).toHaveValue('')
13 | })
14 |
15 | test('should change the input value', async ({ page }) => {
16 | const input = page.getByRole('textbox')
17 |
18 | await input.pressSequentially('1')
19 | await expect(input).toHaveValue('1')
20 |
21 | await input.pressSequentially('23456')
22 | await expect(input).toHaveValue('123456')
23 | })
24 |
25 | test('should prevent typing greater than max length', async ({ page }) => {
26 | const input = page.getByRole('textbox')
27 |
28 | await input.pressSequentially('1234567')
29 | await expect(input).toHaveValue('123457')
30 | })
31 | })
32 |
--------------------------------------------------------------------------------
/apps/test/src/tests/util/modifier.ts:
--------------------------------------------------------------------------------
1 | export const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'
--------------------------------------------------------------------------------
/apps/test/src/tests/with-autofocus.spec.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from '@playwright/test'
2 |
3 | test.beforeEach(async ({ page }) => {
4 | await page.goto('/with-autofocus')
5 | })
6 |
7 | test.describe('With autofocus tests', () => {
8 | test('should autofocus', async ({ page }) => {
9 | const input = page.getByRole('textbox')
10 |
11 | await expect(input).toBeFocused()
12 | })
13 | })
14 |
--------------------------------------------------------------------------------
/apps/test/src/tests/with-on-complete.spec.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from '@playwright/test'
2 | import { modifier } from './util/modifier'
3 |
4 | test.beforeEach(async ({ page }) => {
5 | await page.goto('/with-on-complete')
6 | })
7 |
8 | test.describe('With on complete tests', () => {
9 | test('should change the input value', async ({ page }) => {
10 | const input = page.getByRole('textbox')
11 |
12 | await input.pressSequentially('123456')
13 | await expect(input).toHaveValue('123456')
14 |
15 | await expect(input).toBeDisabled()
16 | })
17 | })
18 |
--------------------------------------------------------------------------------
/apps/test/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const config: Config = {
4 | content: [
5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
8 | ],
9 | theme: {
10 | extend: {
11 | backgroundImage: {
12 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
13 | "gradient-conic":
14 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
15 | },
16 | },
17 | },
18 | plugins: [],
19 | };
20 | export default config;
21 |
--------------------------------------------------------------------------------
/apps/test/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./src/*"]
22 | }
23 | },
24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
25 | "exclude": ["node_modules"]
26 | }
27 |
--------------------------------------------------------------------------------
/apps/website/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/apps/website/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
--------------------------------------------------------------------------------
/apps/website/README.md:
--------------------------------------------------------------------------------
1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
2 |
3 | ## Getting Started
4 |
5 | First, run the development server:
6 |
7 | ```bash
8 | npm run dev
9 | # or
10 | yarn dev
11 | # or
12 | pnpm dev
13 | # or
14 | bun dev
15 | ```
16 |
17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
18 |
19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
20 |
21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
22 |
23 | ## Learn More
24 |
25 | To learn more about Next.js, take a look at the following resources:
26 |
27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
29 |
30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
31 |
32 | ## Deploy on Vercel
33 |
34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
35 |
36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
37 |
--------------------------------------------------------------------------------
/apps/website/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "src/app/globals.css",
9 | "baseColor": "slate",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
--------------------------------------------------------------------------------
/apps/website/next.config.mjs:
--------------------------------------------------------------------------------
1 | import { withHydrationOverlay } from '@builder.io/react-hydration-overlay/next'
2 |
3 | /** @type {import('next').NextConfig} */
4 | const nextProdConfig = {}
5 |
6 | const nextDevConfig = withHydrationOverlay({
7 | appRootSelector: 'main',
8 | })(nextProdConfig)
9 |
10 | export default process.env.NODE_ENV === 'development'
11 | ? nextDevConfig
12 | : nextProdConfig
13 |
--------------------------------------------------------------------------------
/apps/website/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "website",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev --port 3040",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@builder.io/react-hydration-overlay": "^0.0.8",
13 | "@radix-ui/react-dropdown-menu": "^2.0.6",
14 | "@radix-ui/react-icons": "^1.3.0",
15 | "@radix-ui/react-scroll-area": "^1.0.5",
16 | "@radix-ui/react-slot": "^1.0.2",
17 | "class-variance-authority": "^0.7.0",
18 | "clsx": "^2.1.0",
19 | "geist": "^1.2.2",
20 | "input-otp": "workspace:*",
21 | "lucide-react": "^0.330.0",
22 | "next": "14.1.0",
23 | "next-themes": "^0.2.1",
24 | "react": "^18",
25 | "react-canvas-confetti": "^2.0.5",
26 | "react-dom": "^18",
27 | "react-hook-form": "^7.50.1",
28 | "rehype-pretty-code": "^0.13.0",
29 | "rehype-stringify": "^10.0.0",
30 | "remark-parse": "^11.0.0",
31 | "remark-rehype": "^11.1.0",
32 | "sonner": "^1.4.0",
33 | "tailwind-merge": "^2.2.1",
34 | "tailwindcss-animate": "^1.0.7",
35 | "unified": "^11.0.4"
36 | },
37 | "devDependencies": {
38 | "@types/node": "^20",
39 | "@types/react": "^18",
40 | "@types/react-dom": "^18",
41 | "autoprefixer": "^10.0.1",
42 | "eslint": "^8",
43 | "eslint-config-next": "14.1.0",
44 | "postcss": "^8",
45 | "tailwindcss": "^3.4.1",
46 | "typescript": "^5"
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/apps/website/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/apps/website/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/website/public/og.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shadcn/input-otp/6d74289761f3c24fbb2ab907aff481e1038b7e06/apps/website/public/og.jpg
--------------------------------------------------------------------------------
/apps/website/public/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "",
3 | "short_name": "",
4 | "icons": [
5 | {
6 | "src": "/android-chrome-192x192.png",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "/android-chrome-512x512.png",
12 | "sizes": "512x512",
13 | "type": "image/png"
14 | }
15 | ],
16 | "theme_color": "#0D0D0E",
17 | "background_color": "#0D0D0E",
18 | "display": "standalone"
19 | }
--------------------------------------------------------------------------------
/apps/website/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/website/src/app/(local-pages)/example-auto-submit/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 |
5 | import { Button } from '@/components/ui/button'
6 | import { formAction } from './server/form-action'
7 | import { ExampleComponent } from '../example-playground/component'
8 |
9 | export default function ExampleAutoSubmit() {
10 | const formRef = React.useRef(null)
11 |
12 | const [value, setValue] = React.useState('12')
13 |
14 | return (
15 |
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/apps/website/src/app/(local-pages)/example-auto-submit/server/form-action.ts:
--------------------------------------------------------------------------------
1 | 'use server'
2 |
3 | export async function formAction(formData: FormData) {
4 | const rawFormData = {
5 | otp: formData.get('otp'),
6 | }
7 |
8 | console.log({ rawFormData })
9 | }
10 |
--------------------------------------------------------------------------------
/apps/website/src/app/(local-pages)/example-playground/code.tsx:
--------------------------------------------------------------------------------
1 | import { Code } from '@/components/code'
2 | import { useTheme } from 'next-themes'
3 |
4 | const tsx = `'use client'
5 | import { OTPInput, SlotProps } from 'input-otp'
6 |
7 | (
11 | <>
12 |
13 | {slots.slice(0, 3).map((slot, idx) => (
14 |
15 | ))}
16 |
17 |
18 |
19 |
20 |
21 | {slots.slice(3).map((slot, idx) => (
22 |
23 | ))}
24 |
25 | >
26 | )}
27 | />
28 |
29 | // Feel free to copy. Uses @shadcn/ui tailwind colors.
30 | function Slot(props: SlotProps) {
31 | return (
32 |
43 | {props.char !== null &&
{props.char}
}
44 | {props.hasFakeCaret &&
}
45 |
46 | )
47 | }
48 |
49 | // You can emulate a fake textbox caret!
50 | function FakeCaret() {
51 | return (
52 |
55 | )
56 | }
57 |
58 | // Inspired by Stripe's MFA input.
59 | function FakeDash() {
60 | return (
61 |
64 | )
65 | }
66 |
67 | // tailwind.config.ts for the blinking caret animation.
68 | const config = {
69 | theme: {
70 | extend: {
71 | keyframes: {
72 | 'caret-blink': {
73 | '0%,70%,100%': { opacity: '1' },
74 | '20%,50%': { opacity: '0' },
75 | },
76 | },
77 | animation: {
78 | 'caret-blink': 'caret-blink 1.2s ease-out infinite',
79 | },
80 | },
81 | },
82 | }
83 |
84 | // Small utility to merge class names.
85 | import { clsx } from "clsx";
86 | import { twMerge } from "tailwind-merge";
87 |
88 | import type { ClassValue } from "clsx";
89 |
90 | export function cn(...inputs: ClassValue[]) {
91 | return twMerge(clsx(inputs));
92 | }
93 | `
94 |
95 | const code = `\`\`\`tsx /maxLength={6}/ /render/ /slots/1 /.map((slot, idx)/1 /Slot/2,3,4 /props.char/2 / /
96 | ${tsx}
97 | \`\`\``
98 |
99 | export function ExampleCode() {
100 | return (
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 | {/* Anchor */}
110 |
111 |
112 | )
113 | }
114 |
--------------------------------------------------------------------------------
/apps/website/src/app/(local-pages)/example-playground/component.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import { OTPInput, SlotProps } from 'input-otp'
5 |
6 | import { cn } from '@/lib/utils'
7 |
8 | export function ExampleComponent(
9 | props: Partial, 'children'>>,
10 | ) {
11 | return (
12 | (
17 | <>
18 |
19 | {slots.slice(0, 3).map((slot, idx) => (
20 |
21 | ))}
22 |
23 |
24 |
25 |
26 |
27 | {slots.slice(3).map((slot, idx) => (
28 |
29 | ))}
30 |
31 | >
32 | )}
33 | />
34 | )
35 | }
36 |
37 | // Feel free to copy. Uses @shadcn/ui tailwind colors.
38 | function Slot(props: SlotProps) {
39 | return (
40 |
51 | {props.char !== null &&
{props.char}
}
52 | {props.hasFakeCaret &&
}
53 |
54 | )
55 | }
56 |
57 | // You can emulate a fake textbox caret!
58 | function FakeCaret() {
59 | return (
60 |
63 | )
64 | }
65 |
66 | // Inspired by Stripe's MFA input.
67 | function FakeDash() {
68 | return (
69 |
72 | )
73 | }
74 |
75 | // tailwind.config.ts for the blinking caret animation.
76 | const config = {
77 | theme: {
78 | extend: {
79 | keyframes: {
80 | 'caret-blink': {
81 | '0%,70%,100%': { opacity: '1' },
82 | '20%,50%': { opacity: '0' },
83 | },
84 | },
85 | animation: {
86 | 'caret-blink': 'caret-blink 1.2s ease-out infinite',
87 | },
88 | },
89 | },
90 | }
91 |
92 | // Small utility to merge class names.
93 | // import { clsx } from "clsx";
94 | // import { twMerge } from "tailwind-merge";
95 |
96 | // import type { ClassValue } from "clsx";
97 |
98 | // export function cn(...inputs: ClassValue[]) {
99 | // return twMerge(clsx(inputs));
100 | // }
101 |
--------------------------------------------------------------------------------
/apps/website/src/app/(local-pages)/example-playground/page.tsx:
--------------------------------------------------------------------------------
1 | import { ExampleComponent } from './component'
2 |
3 | export default function ExamplePlayground() {
4 | return (
5 |
6 |
7 |
8 | )
9 | }
10 |
--------------------------------------------------------------------------------
/apps/website/src/app/(local-pages)/layout.tsx:
--------------------------------------------------------------------------------
1 |
2 | import { Toaster } from '@/components/ui/sonner'
3 | import { AppProvider } from '../../components/provider'
4 | import { fontSans } from '../../lib/fonts'
5 | import { cn } from '../../lib/utils'
6 | import '../globals.css'
7 | export default function RootLayout({
8 | children,
9 | }: Readonly<{
10 | children: React.ReactNode
11 | }>) {
12 | return (
13 |
14 |
20 |
21 |
22 | {/* */}
23 | {children}
24 | {/* */}
25 |
26 |
27 |
28 |
29 |
30 |
31 | )
32 | }
33 |
--------------------------------------------------------------------------------
/apps/website/src/app/(local-pages)/raw-input/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React from 'react'
4 |
5 | export default function ShadcnPage() {
6 | const [value, setValue] = React.useState('')
7 |
8 | return (
9 |
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/apps/website/src/app/(local-pages)/shadcn/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import {
4 | InputOTP,
5 | InputOTPGroup,
6 | InputOTPRenderSlot,
7 | InputOTPSeparator,
8 | InputOTPSlot,
9 | } from '@/components/ui/input-otp'
10 | import React from 'react'
11 |
12 | export default function ShadcnPage() {
13 | const [value, setValue] = React.useState('')
14 |
15 | return (
16 |
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/apps/website/src/app/(local-pages)/shadcn/pwmb/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import {
4 | InputOTP,
5 | InputOTPGroup,
6 | InputOTPRenderSlot,
7 | InputOTPSeparator,
8 | InputOTPSlot,
9 | } from '@/components/ui/input-otp'
10 | import { REGEXP_ONLY_DIGITS_AND_CHARS } from 'input-otp'
11 | import React from 'react'
12 |
13 | export default function ShadcnPage() {
14 | const [value, setValue] = React.useState('')
15 |
16 | return (
17 |
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/apps/website/src/app/(local-pages)/shadcn/static/page.tsx:
--------------------------------------------------------------------------------
1 | 'use server'
2 |
3 | import { default as ShadcnPage } from '../page'
4 |
5 | export default async function StaticPage(pageProps: any) {
6 | return (
7 |
8 | )
9 | }
10 |
--------------------------------------------------------------------------------
/apps/website/src/app/(pages)/(home)/_components/confetti.tsx:
--------------------------------------------------------------------------------
1 | import VFX from 'react-canvas-confetti/dist/presets/explosion'
2 |
3 | export function Confetti() {
4 | return (
5 |
9 | )
10 | }
11 |
--------------------------------------------------------------------------------
/apps/website/src/app/(pages)/(home)/_components/showcase.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React, { useMemo } from 'react'
4 | import { toast } from 'sonner'
5 | import dynamic from 'next/dynamic'
6 |
7 | import { OTPInput, REGEXP_ONLY_DIGITS } from 'input-otp'
8 | import { cn } from '@/lib/utils'
9 |
10 | const DynamicConfetti = dynamic(() =>
11 | import('./confetti').then(m => m.Confetti),
12 | )
13 |
14 | export function Showcase({ className, ...props }: { className?: string }) {
15 | const [value, setValue] = React.useState('12')
16 | const [disabled, setDisabled] = React.useState(false)
17 |
18 | const [preloadConfetti, setPreloadConfetti] = React.useState(0)
19 | const [hasGuessed, setHasGuessed] = React.useState(false)
20 |
21 | const inputRef = React.useRef(null)
22 |
23 | React.useEffect(() => {
24 | const isMobile = window.matchMedia('(max-width: 1023px)').matches
25 | if (!isMobile) {
26 | setDisabled(true)
27 | }
28 | const t1 = setTimeout(() => {
29 | setDisabled(false)
30 | }, 1_900)
31 | const t2 = setTimeout(
32 | () => {
33 | inputRef.current?.focus()
34 | },
35 | isMobile ? 0 : 2_500,
36 | )
37 |
38 | return () => {
39 | clearTimeout(t1)
40 | clearTimeout(t2)
41 | }
42 | }, [])
43 |
44 | React.useEffect(() => {
45 | if (value.length > 3) {
46 | setPreloadConfetti(p => p + 1)
47 | }
48 | }, [value.length])
49 |
50 | async function onSubmit(e?: React.FormEvent) {
51 | e?.preventDefault?.()
52 |
53 | inputRef.current?.select()
54 | await new Promise(r => setTimeout(r, 1_00))
55 |
56 | if (value === '123456') {
57 | setHasGuessed(true)
58 |
59 | setTimeout(() => {
60 | setHasGuessed(false)
61 | }, 1_000)
62 | } else {
63 | toast('Try guessing the right password 🤔', { position: 'top-right' })
64 | }
65 |
66 | const anchor = document.querySelector(
67 | '.code-example-anchor',
68 | )
69 |
70 | window.scrollTo({
71 | top: anchor?.getBoundingClientRect().top,
72 | behavior: 'smooth',
73 | })
74 |
75 | setValue('')
76 | setTimeout(() => {
77 | inputRef.current?.blur()
78 | }, 20)
79 | }
80 |
81 | return (
82 | <>
83 | {preloadConfetti === 1 && (
84 |
85 |
86 |
87 | )}
88 | {hasGuessed && (
89 |
90 |
91 |
92 | )}
93 |
94 |
138 | >
139 | )
140 | }
141 |
142 | function Slot(props: {
143 | char: string | null
144 | isActive: boolean
145 | isFocused: boolean
146 | animateIdx?: number
147 | }) {
148 | const willAnimateChar = props.animateIdx !== undefined && props.animateIdx < 2
149 | const willAnimateCaret = props.animateIdx === 2
150 |
151 | return (
152 |
161 |
168 | {props.char &&
{props.char}
}
169 | {props.char === null && ' '}
170 |
171 |
172 | {props.isActive && props.char === null && (
173 |
178 |
179 |
180 | )}
181 |
182 | )
183 | }
184 |
185 | function FakeCaret() {
186 | return (
187 |
190 | )
191 | }
192 |
--------------------------------------------------------------------------------
/apps/website/src/app/(pages)/(home)/page.tsx:
--------------------------------------------------------------------------------
1 | import { CopyNpmCommandButton } from '@/components/copy-button'
2 | import { Icons } from '@/components/icons'
3 | import {
4 | PageActions,
5 | PageHeader,
6 | PageHeaderDescription,
7 | PageHeaderHeading,
8 | } from '@/components/page-header'
9 | import { buttonVariants } from '@/components/ui/button'
10 | import { siteConfig } from '@/config/site'
11 | import { cn } from '@/lib/utils'
12 | import Link from 'next/link'
13 | import { Showcase } from './_components/showcase'
14 | import { ExampleCode } from '@/app/(local-pages)/example-playground/code'
15 |
16 | const fadeUpClassname =
17 | 'lg:motion-safe:opacity-0 lg:motion-safe:animate-fade-up'
18 |
19 | async function getRepoStarCount() {
20 | const res = await fetch(
21 | 'https://api.github.com/repos/guilhermerodz/input-otp',
22 | )
23 | const data = await res.json()
24 | const starCount = data.stargazers_count
25 |
26 | if (starCount > 999) {
27 | return (starCount / 1000).toFixed(1) + 'K'
28 | }
29 |
30 | return starCount
31 | }
32 |
33 | export default async function IndexPage() {
34 | const starCount = await getRepoStarCount()
35 |
36 | return (
37 |
38 |
39 |
40 | Stop wasting time building OTP inputs.
41 |
42 |
43 |
49 |
50 |
56 | One-time password input component for React. Accessible. Unstyled.
57 | Customizable. Open Source.
58 |
59 |
60 |
66 |
67 |
68 | npm install input-otp
69 |
70 |
78 |
79 |
88 |
89 |
90 |
Github
91 |
92 |
{starCount}
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 | )
101 | }
102 |
103 | export const revalidate = 3600
104 |
--------------------------------------------------------------------------------
/apps/website/src/app/(pages)/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next'
2 |
3 | // import { HydrationOverlay } from '@builder.io/react-hydration-overlay'
4 |
5 | import { SiteFooter } from '../../components/site-footer'
6 | import { SiteHeader } from '../../components/site-header'
7 | import { siteConfig } from '../../config/site'
8 | import { fontSans } from '../../lib/fonts'
9 | import { cn } from '../../lib/utils'
10 | import '../globals.css'
11 | import { AppProvider } from '../../components/provider'
12 | import { Toaster } from '@/components/ui/sonner'
13 |
14 | export const metadata: Metadata = {
15 | title: {
16 | default: siteConfig.name,
17 | template: `%s - ${siteConfig.name}`,
18 | },
19 | metadataBase: new URL(siteConfig.url),
20 | description: siteConfig.description,
21 | keywords: [
22 | 'React',
23 | 'one-time-code',
24 | 'Input',
25 | 'Next.js',
26 | 'Tailwind CSS',
27 | 'Server Components',
28 | 'Accessible',
29 | ],
30 | authors: [
31 | {
32 | name: 'guilhermerodz',
33 | url: 'https://rodz.dev',
34 | },
35 | ],
36 | creator: 'guilhermerodz',
37 | openGraph: {
38 | type: 'website',
39 | locale: 'en_US',
40 | url: siteConfig.url,
41 | title: siteConfig.name,
42 | description: siteConfig.description,
43 | siteName: siteConfig.name,
44 | images: [
45 | {
46 | url: siteConfig.ogImage,
47 | width: 1200,
48 | height: 630,
49 | alt: siteConfig.name,
50 | },
51 | ],
52 | },
53 | twitter: {
54 | card: 'summary_large_image',
55 | title: siteConfig.name,
56 | description: siteConfig.description,
57 | images: [siteConfig.ogImage],
58 | creator: '@guilherme_rodz',
59 | },
60 | icons: {
61 | icon: '/favicon.ico',
62 | shortcut: '/favicon-16x16.png',
63 | apple: '/apple-touch-icon.png',
64 | },
65 | manifest: `${siteConfig.url}/site.webmanifest`,
66 | }
67 |
68 | export default function RootLayout({
69 | children,
70 | }: Readonly<{
71 | children: React.ReactNode
72 | }>) {
73 | return (
74 |
75 |
81 |
82 |
83 |
84 |
85 | {/* */}
86 | {children}
87 | {/* */}
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 | )
97 | }
98 |
--------------------------------------------------------------------------------
/apps/website/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shadcn/input-otp/6d74289761f3c24fbb2ab907aff481e1038b7e06/apps/website/src/app/favicon.ico
--------------------------------------------------------------------------------
/apps/website/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 240 10% 3.9%;
9 | --card: 0 0% 100%;
10 | --card-foreground: 240 10% 3.9%;
11 | --popover: 0 0% 100%;
12 | --popover-foreground: 240 10% 3.9%;
13 | --primary: 240 5.9% 10%;
14 | --primary-foreground: 0 0% 98%;
15 | --secondary: 240 4.8% 95.9%;
16 | --secondary-foreground: 240 5.9% 10%;
17 | --muted: 240 4.8% 95.9%;
18 | --muted-foreground: 240 3.8% 46.1%;
19 | --accent: 240 4.8% 95.9%;
20 | --accent-foreground: 240 5.9% 10%;
21 | --destructive: 0 72.22% 50.59%;
22 | --destructive-foreground: 0 0% 98%;
23 | --border: 240 5.9% 90%;
24 | --input: 240 5.9% 90%;
25 | --ring: 240 5% 64.9%;
26 | --radius: 0.5rem;
27 | }
28 |
29 | .dark {
30 | --background: 240 10% 3.9%;
31 | --foreground: 0 0% 98%;
32 | --card: 240 10% 3.9%;
33 | --card-foreground: 0 0% 98%;
34 | --popover: 240 10% 3.9%;
35 | --popover-foreground: 0 0% 98%;
36 | --primary: 0 0% 98%;
37 | --primary-foreground: 240 5.9% 10%;
38 | --secondary: 240 3.7% 15.9%;
39 | --secondary-foreground: 0 0% 98%;
40 | --muted: 240 3.7% 15.9%;
41 | --muted-foreground: 240 5% 64.9%;
42 | --accent: 240 3.7% 15.9%;
43 | --accent-foreground: 0 0% 98%;
44 | --destructive: 0 62.8% 30.6%;
45 | --destructive-foreground: 0 85.7% 97.3%;
46 | --border: 240 3.7% 15.9%;
47 | --input: 240 3.7% 15.9%;
48 | --ring: 240 4.9% 83.9%;
49 | }
50 | }
51 |
52 | @media (prefers-color-scheme: dark) {
53 | :root {
54 | --background: 240 10% 3.9%;
55 | --foreground: 0 0% 98%;
56 | --card: 240 10% 3.9%;
57 | --card-foreground: 0 0% 98%;
58 | --popover: 240 10% 3.9%;
59 | --popover-foreground: 0 0% 98%;
60 | --primary: 0 0% 98%;
61 | --primary-foreground: 240 5.9% 10%;
62 | --secondary: 240 3.7% 15.9%;
63 | --secondary-foreground: 0 0% 98%;
64 | --muted: 240 3.7% 15.9%;
65 | --muted-foreground: 240 5% 64.9%;
66 | --accent: 240 3.7% 15.9%;
67 | --accent-foreground: 0 0% 98%;
68 | --destructive: 0 62.8% 30.6%;
69 | --destructive-foreground: 0 85.7% 97.3%;
70 | --border: 240 3.7% 15.9%;
71 | --input: 240 3.7% 15.9%;
72 | --ring: 240 4.9% 83.9%;
73 | }
74 | }
75 |
76 | @layer base {
77 | * {
78 | @apply border-border;
79 | }
80 | body {
81 | /* @apply bg-background text-foreground selection:bg-[#6B2BF4] selection:text-foreground; */
82 | @apply bg-background text-foreground;
83 | /* font-feature-settings: "rlig" 1, "calt" 1; */
84 | font-synthesis-weight: none;
85 | text-rendering: optimizeLegibility;
86 | }
87 | }
88 |
89 | @layer utilities {
90 | }
91 |
92 | [data-highlighted-chars] {
93 | @apply bg-zinc-900 rounded;
94 | box-shadow: 2px 2px 0 2px rgba(139, 139, 148, 0.5);
95 | }
96 | [data-highlighted-chars] .dark {
97 | @apply bg-zinc-700/50 rounded;
98 | box-shadow: 2px 2px 0 2px rgba(139, 139, 148, 0.5);
99 | }
100 | [data-highlighted-chars] * {
101 | @apply !text-white;
102 | }
103 | [data-rehype-pretty-code-figure] pre {
104 | @apply pb-4 pt-6 max-h-[650px] overflow-x-auto rounded-lg border !bg-transparent;
105 | }
106 | [data-rehype-pretty-code-figure] [data-line] {
107 | @apply inline-block min-h-4 w-full py-0.5 px-4;
108 | }
109 |
110 | .code-example-overlay {
111 | background-image: linear-gradient(
112 | to bottom,
113 | theme('colors.background') 60%,
114 | transparent
115 | );
116 | transform: translateY(0);
117 | animation: move-overlay 4s ease-out forwards;
118 | animation-delay: 3s;
119 | }
120 | .code-example-light {
121 | }
122 | .code-example-dark {
123 | display: none;
124 | }
125 | @media (prefers-color-scheme: dark) {
126 | .code-example-light {
127 | display: none;
128 | }
129 | .code-example-dark {
130 | display: unset;
131 | }
132 | }
133 |
134 | @media (prefers-reduced-motion: reduce) {
135 | .code-example-overlay {
136 | opacity: 0;
137 | animation: none;
138 | }
139 | }
140 | @keyframes move-overlay {
141 | 0% {
142 | transform: translateY(0);
143 | }
144 | 100% {
145 | transform: translateY(-100%);
146 | }
147 | }
148 |
--------------------------------------------------------------------------------
/apps/website/src/components/code.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { unified } from 'unified'
3 | import remarkParse from 'remark-parse'
4 | import remarkRehype from 'remark-rehype'
5 | import rehypeStringify from 'rehype-stringify'
6 | import rehypePrettyCode from 'rehype-pretty-code'
7 | import { CopyButton } from './copy-button'
8 |
9 | export async function Code({
10 | code,
11 | toCopy,
12 | dark = true,
13 | }: {
14 | code: string
15 | toCopy?: string
16 | dark?: boolean
17 | }) {
18 | const highlightedCode = await highlightCode(code, dark)
19 | return (
20 |
21 |
26 |
27 | {toCopy && (
28 |
29 |
30 |
31 | )}
32 |
33 | )
34 | }
35 |
36 | async function highlightCode(code: string, dark: boolean) {
37 | const file = await unified()
38 | .use(remarkParse)
39 | .use(remarkRehype)
40 | .use(rehypePrettyCode, {
41 | keepBackground: false,
42 | theme: dark ? 'vesper' : 'github-light',
43 | })
44 | .use(rehypeStringify)
45 | .process(code)
46 |
47 | return String(file)
48 | }
49 |
--------------------------------------------------------------------------------
/apps/website/src/components/copy-button.tsx:
--------------------------------------------------------------------------------
1 | // Stolen from @shadcn/ui the man the machine!!
2 | 'use client'
3 |
4 | import * as React from 'react'
5 | import { CheckIcon, CopyIcon } from '@radix-ui/react-icons'
6 | import type { DropdownMenuTriggerProps } from '@radix-ui/react-dropdown-menu'
7 |
8 | import { cn } from '@/lib/utils'
9 | import { Button } from './ui/button'
10 | import {
11 | DropdownMenu,
12 | DropdownMenuContent,
13 | DropdownMenuItem,
14 | DropdownMenuTrigger,
15 | } from './ui/dropdown-menu'
16 |
17 | interface CopyButtonProps extends React.HTMLAttributes {
18 | value: string
19 | src?: string
20 | }
21 |
22 | export async function copyToClipboardWithMeta(value: string) {
23 | window && window.isSecureContext && navigator.clipboard.writeText(value)
24 | }
25 |
26 | export function CopyButton({
27 | value,
28 | className,
29 | src,
30 | ...props
31 | }: CopyButtonProps) {
32 | const [hasCopied, setHasCopied] = React.useState(false)
33 |
34 | React.useEffect(() => {
35 | setTimeout(() => {
36 | setHasCopied(false)
37 | }, 2000)
38 | }, [hasCopied])
39 |
40 | return (
41 | {
49 | copyToClipboardWithMeta(value)
50 | setHasCopied(true)
51 | }}
52 | {...props}
53 | >
54 | Copy
55 | {hasCopied ? (
56 |
57 | ) : (
58 |
59 | )}
60 |
61 | )
62 | }
63 |
64 | interface CopyWithClassNamesProps extends DropdownMenuTriggerProps {
65 | value: string
66 | classNames: string
67 | className?: string
68 | }
69 |
70 | export function CopyWithClassNames({
71 | value,
72 | classNames,
73 | className,
74 | ...props
75 | }: CopyWithClassNamesProps) {
76 | const [hasCopied, setHasCopied] = React.useState(false)
77 |
78 | React.useEffect(() => {
79 | setTimeout(() => {
80 | setHasCopied(false)
81 | }, 2000)
82 | }, [hasCopied])
83 |
84 | const copyToClipboard = React.useCallback((value: string) => {
85 | copyToClipboardWithMeta(value)
86 | setHasCopied(true)
87 | }, [])
88 |
89 | return (
90 |
91 |
92 |
100 | {hasCopied ? (
101 |
102 | ) : (
103 |
104 | )}
105 | Copy
106 |
107 |
108 |
109 | copyToClipboard(value)}>
110 | Component
111 |
112 | copyToClipboard(classNames)}>
113 | Classname
114 |
115 |
116 |
117 | )
118 | }
119 |
120 | interface CopyNpmCommandButtonProps extends DropdownMenuTriggerProps {
121 | commands: {
122 | __npmCommand__: string
123 | __yarnCommand__: string
124 | __pnpmCommand__: string
125 | __bunCommand__: string
126 | }
127 | }
128 |
129 | export function CopyNpmCommandButton({
130 | commands,
131 | className,
132 | ...props
133 | }: CopyNpmCommandButtonProps) {
134 | const [hasCopied, setHasCopied] = React.useState(false)
135 |
136 | React.useEffect(() => {
137 | setTimeout(() => {
138 | setHasCopied(false)
139 | }, 2000)
140 | }, [hasCopied])
141 |
142 | const copyCommand = React.useCallback(
143 | (value: string, pm: 'npm' | 'pnpm' | 'yarn' | 'bun') => {
144 | copyToClipboardWithMeta(value)
145 | setHasCopied(true)
146 | },
147 | [],
148 | )
149 |
150 | return (
151 |
152 |
153 |
161 | {hasCopied ? (
162 |
163 | ) : (
164 |
165 | )}
166 | Copy
167 |
168 |
169 |
170 | copyCommand(commands.__npmCommand__, 'npm')}
172 | >
173 | npm
174 |
175 | copyCommand(commands.__yarnCommand__, 'yarn')}
177 | >
178 | yarn
179 |
180 | copyCommand(commands.__pnpmCommand__, 'pnpm')}
182 | >
183 | pnpm
184 |
185 | copyCommand(commands.__bunCommand__, 'bun')}
187 | >
188 | bun
189 |
190 |
191 |
192 | )
193 | }
194 |
--------------------------------------------------------------------------------
/apps/website/src/components/icons.tsx:
--------------------------------------------------------------------------------
1 | type IconProps = React.HTMLAttributes
2 |
3 | export const Icons = {
4 | logo: (props: IconProps) => (
5 |
6 |
7 |
18 |
29 |
30 | ),
31 | twitter: (props: IconProps) => (
32 |
39 |
40 |
41 | ),
42 | gitHub: (props: IconProps) => (
43 |
44 |
48 |
49 | ),
50 | radix: (props: IconProps) => (
51 |
52 |
56 |
57 |
61 |
62 | ),
63 | aria: (props: IconProps) => (
64 |
65 |
66 |
67 | ),
68 | npm: (props: IconProps) => (
69 |
70 |
74 |
75 | ),
76 | yarn: (props: IconProps) => (
77 |
78 |
82 |
83 | ),
84 | pnpm: (props: IconProps) => (
85 |
86 |
90 |
91 | ),
92 | react: (props: IconProps) => (
93 |
94 |
98 |
99 | ),
100 | tailwind: (props: IconProps) => (
101 |
102 |
106 |
107 | ),
108 | google: (props: IconProps) => (
109 |
110 |
114 |
115 | ),
116 | apple: (props: IconProps) => (
117 |
118 |
122 |
123 | ),
124 | paypal: (props: IconProps) => (
125 |
126 |
130 |
131 | ),
132 | spinner: (props: IconProps) => (
133 |
145 |
146 |
147 | ),
148 | }
149 |
--------------------------------------------------------------------------------
/apps/website/src/components/mode-toggle.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { MoonIcon, SunIcon } from "@radix-ui/react-icons"
5 | import { useTheme } from "next-themes"
6 |
7 | import { Button } from "@/components/ui/button"
8 | import {
9 | DropdownMenu,
10 | DropdownMenuContent,
11 | DropdownMenuItem,
12 | DropdownMenuTrigger,
13 | } from "@/components/ui/dropdown-menu"
14 |
15 | export function ModeToggle() {
16 | const { setTheme } = useTheme()
17 |
18 | return (
19 |
20 |
21 |
22 |
23 |
24 | Toggle theme
25 |
26 |
27 |
28 | setTheme("light")}>
29 | Light
30 |
31 | setTheme("dark")}>
32 | Dark
33 |
34 | setTheme("system")}>
35 | System
36 |
37 |
38 |
39 | )
40 | }
41 |
--------------------------------------------------------------------------------
/apps/website/src/components/page-header.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "../lib/utils"
2 |
3 | function PageHeader({
4 | className,
5 | children,
6 | ...props
7 | }: React.HTMLAttributes) {
8 | return (
9 |
18 | )
19 | }
20 |
21 | function PageHeaderHeading({
22 | className,
23 | ...props
24 | }: React.HTMLAttributes) {
25 | return (
26 |
33 | )
34 | }
35 |
36 | function PageHeaderDescription({
37 | className,
38 | ...props
39 | }: React.HTMLAttributes) {
40 | return (
41 |
48 | )
49 | }
50 |
51 | function PageActions({
52 | className,
53 | ...props
54 | }: React.HTMLAttributes) {
55 | return (
56 |
63 | )
64 | }
65 |
66 | export { PageHeader, PageHeaderHeading, PageHeaderDescription, PageActions }
--------------------------------------------------------------------------------
/apps/website/src/components/provider.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { ThemeProvider } from 'next-themes'
4 |
5 | export function AppProvider({
6 | children,
7 | ...props
8 | }: React.PropsWithChildren<{}>) {
9 | return (
10 |
16 | {children}
17 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/apps/website/src/components/site-footer.tsx:
--------------------------------------------------------------------------------
1 | import { siteConfig } from "../config/site"
2 |
3 | export function SiteFooter() {
4 | return (
5 |
30 | )
31 | }
--------------------------------------------------------------------------------
/apps/website/src/components/site-header.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link"
2 |
3 | import { siteConfig } from "../config/site"
4 | import { cn } from "../lib/utils"
5 | import { buttonVariants } from "./ui/button"
6 | import { Icons } from "./icons"
7 | import { ModeToggle } from "./mode-toggle"
8 |
9 | export function SiteHeader() {
10 | return (
11 |
12 |
13 | {/*
14 |
*/}
15 |
16 | {/*
17 |
18 |
*/}
19 |
20 |
25 |
33 |
34 | GitHub
35 |
36 |
37 |
42 |
50 |
51 | Twitter
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | )
60 | }
--------------------------------------------------------------------------------
/apps/website/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "../../lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
13 | destructive:
14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90",
15 | outline:
16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
17 | secondary:
18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19 | ghost: "hover:bg-accent hover:text-accent-foreground",
20 | link: "text-primary underline-offset-4 hover:underline",
21 | },
22 | size: {
23 | default: "h-10 px-4 py-2",
24 | sm: "h-9 rounded-md px-3",
25 | lg: "h-11 rounded-md px-8",
26 | icon: "h-10 w-10",
27 | },
28 | },
29 | defaultVariants: {
30 | variant: "default",
31 | size: "default",
32 | },
33 | }
34 | )
35 |
36 | export interface ButtonProps
37 | extends React.ButtonHTMLAttributes,
38 | VariantProps {
39 | asChild?: boolean
40 | }
41 |
42 | const Button = React.forwardRef(
43 | ({ className, variant, size, asChild = false, ...props }, ref) => {
44 | const Comp = asChild ? Slot : "button"
45 | return (
46 |
51 | )
52 | }
53 | )
54 | Button.displayName = "Button"
55 |
56 | export { Button, buttonVariants }
57 |
--------------------------------------------------------------------------------
/apps/website/src/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
5 | import { Check, ChevronRight, Circle } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const DropdownMenu = DropdownMenuPrimitive.Root
10 |
11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
12 |
13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group
14 |
15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal
16 |
17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub
18 |
19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
20 |
21 | const DropdownMenuSubTrigger = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef & {
24 | inset?: boolean
25 | }
26 | >(({ className, inset, children, ...props }, ref) => (
27 |
36 | {children}
37 |
38 |
39 | ))
40 | DropdownMenuSubTrigger.displayName =
41 | DropdownMenuPrimitive.SubTrigger.displayName
42 |
43 | const DropdownMenuSubContent = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef
46 | >(({ className, ...props }, ref) => (
47 |
55 | ))
56 | DropdownMenuSubContent.displayName =
57 | DropdownMenuPrimitive.SubContent.displayName
58 |
59 | const DropdownMenuContent = React.forwardRef<
60 | React.ElementRef,
61 | React.ComponentPropsWithoutRef
62 | >(({ className, sideOffset = 4, ...props }, ref) => (
63 |
64 |
73 |
74 | ))
75 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
76 |
77 | const DropdownMenuItem = React.forwardRef<
78 | React.ElementRef,
79 | React.ComponentPropsWithoutRef & {
80 | inset?: boolean
81 | }
82 | >(({ className, inset, ...props }, ref) => (
83 |
92 | ))
93 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
94 |
95 | const DropdownMenuCheckboxItem = React.forwardRef<
96 | React.ElementRef,
97 | React.ComponentPropsWithoutRef
98 | >(({ className, children, checked, ...props }, ref) => (
99 |
108 |
109 |
110 |
111 |
112 |
113 | {children}
114 |
115 | ))
116 | DropdownMenuCheckboxItem.displayName =
117 | DropdownMenuPrimitive.CheckboxItem.displayName
118 |
119 | const DropdownMenuRadioItem = React.forwardRef<
120 | React.ElementRef,
121 | React.ComponentPropsWithoutRef
122 | >(({ className, children, ...props }, ref) => (
123 |
131 |
132 |
133 |
134 |
135 |
136 | {children}
137 |
138 | ))
139 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
140 |
141 | const DropdownMenuLabel = React.forwardRef<
142 | React.ElementRef,
143 | React.ComponentPropsWithoutRef & {
144 | inset?: boolean
145 | }
146 | >(({ className, inset, ...props }, ref) => (
147 |
156 | ))
157 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
158 |
159 | const DropdownMenuSeparator = React.forwardRef<
160 | React.ElementRef,
161 | React.ComponentPropsWithoutRef
162 | >(({ className, ...props }, ref) => (
163 |
168 | ))
169 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
170 |
171 | const DropdownMenuShortcut = ({
172 | className,
173 | ...props
174 | }: React.HTMLAttributes) => {
175 | return (
176 |
180 | )
181 | }
182 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
183 |
184 | export {
185 | DropdownMenu,
186 | DropdownMenuTrigger,
187 | DropdownMenuContent,
188 | DropdownMenuItem,
189 | DropdownMenuCheckboxItem,
190 | DropdownMenuRadioItem,
191 | DropdownMenuLabel,
192 | DropdownMenuSeparator,
193 | DropdownMenuShortcut,
194 | DropdownMenuGroup,
195 | DropdownMenuPortal,
196 | DropdownMenuSub,
197 | DropdownMenuSubContent,
198 | DropdownMenuSubTrigger,
199 | DropdownMenuRadioGroup,
200 | }
201 |
--------------------------------------------------------------------------------
/apps/website/src/components/ui/input-otp.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import { OTPInput, OTPInputContext, SlotProps } from 'input-otp'
5 | import { Dot } from 'lucide-react'
6 |
7 | import { cn } from '@/lib/utils'
8 |
9 | const InputOTP = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ containerClassName, className, ...props }, ref) => (
13 |
22 | ))
23 | InputOTP.displayName = 'InputOTP'
24 |
25 | const InputOTPGroup = React.forwardRef<
26 | React.ElementRef<'div'>,
27 | React.ComponentPropsWithoutRef<'div'>
28 | >(({ className, ...props }, ref) => (
29 |
30 | ))
31 | InputOTPGroup.displayName = 'InputOTPGroup'
32 |
33 | type InputOTPSlotProps = { index: number }
34 | const InputOTPSlot = React.forwardRef<
35 | React.ElementRef<'div'>,
36 | React.ComponentPropsWithoutRef<'div'> & InputOTPSlotProps
37 | >(({ index, className, ...props }, ref) => {
38 | const inputContext = React.useContext(OTPInputContext)
39 | const slotProps = inputContext.slots[index]
40 | return (
41 |
50 | {slotProps.char}
51 | {slotProps.hasFakeCaret && (
52 |
55 | )}
56 |
57 | )
58 | })
59 | InputOTPSlot.displayName = 'InputOTPSlot'
60 |
61 | const InputOTPRenderSlot = React.forwardRef<
62 | React.ElementRef<'div'>,
63 | SlotProps & React.ComponentPropsWithoutRef<'div'>
64 | >(({ char, hasFakeCaret, isActive, className, ...props }, ref) => {
65 | return (
66 |
75 | {char}
76 | {hasFakeCaret && (
77 |
80 | )}
81 |
82 | )
83 | })
84 | InputOTPRenderSlot.displayName = 'InputOTPRenderSlot'
85 |
86 | const InputOTPSeparator = React.forwardRef<
87 | React.ElementRef<'div'>,
88 | React.ComponentPropsWithoutRef<'div'>
89 | >(({ ...props }, ref) => (
90 |
91 |
92 |
93 | ))
94 | InputOTPSeparator.displayName = 'InputOTPSeparator'
95 |
96 | export {
97 | InputOTP,
98 | InputOTPGroup,
99 | InputOTPRenderSlot,
100 | InputOTPSlot,
101 | InputOTPSeparator,
102 | }
103 |
--------------------------------------------------------------------------------
/apps/website/src/components/ui/scroll-area.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const ScrollArea = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, children, ...props }, ref) => (
12 |
17 |
18 | {children}
19 |
20 |
21 |
22 |
23 | ))
24 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
25 |
26 | const ScrollBar = React.forwardRef<
27 | React.ElementRef,
28 | React.ComponentPropsWithoutRef
29 | >(({ className, orientation = "vertical", ...props }, ref) => (
30 |
43 |
44 |
45 | ))
46 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
47 |
48 | export { ScrollArea, ScrollBar }
49 |
--------------------------------------------------------------------------------
/apps/website/src/components/ui/sonner.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useTheme } from 'next-themes'
4 | import { Toaster as Sonner } from 'sonner'
5 |
6 | type ToasterProps = React.ComponentProps
7 |
8 | const Toaster = ({ ...props }: ToasterProps) => {
9 | const { theme = 'system' } = useTheme()
10 |
11 | return (
12 |
30 | )
31 | }
32 |
33 | export { Toaster }
34 |
--------------------------------------------------------------------------------
/apps/website/src/config/site.ts:
--------------------------------------------------------------------------------
1 | export const siteConfig = {
2 | name: 'rodz/input-otp',
3 | url: 'https://input-otp.rodz.dev',
4 | ogImage: 'https://input-otp.rodz.dev/og.jpg',
5 | description:
6 | 'One-time password input component for React. Accessible. Unstyled. Customizable. Open Source. Build your own OTP form effortlessly.',
7 | links: {
8 | twitter: 'https://twitter.com/guilherme_rodz',
9 | github: 'https://github.com/guilhermerodz/input-otp',
10 | },
11 | }
12 |
13 | export type SiteConfig = typeof siteConfig
14 |
--------------------------------------------------------------------------------
/apps/website/src/lib/fonts.ts:
--------------------------------------------------------------------------------
1 | // import { JetBrains_Mono as FontMono, Inter as FontSans } from "next/font/google"
2 | import { JetBrains_Mono as FontMono } from 'next/font/google'
3 | // import { GeistMono } from "geist/font/mono"
4 | import { GeistSans } from 'geist/font/sans'
5 |
6 | // export const fontSans = FontSans({
7 | // subsets: ["latin"],
8 | // variable: "--font-sans",
9 | // })
10 | export const fontSans = GeistSans
11 |
12 | export const fontMono = FontMono({
13 | subsets: ['latin'],
14 | variable: '--font-mono',
15 | })
16 |
--------------------------------------------------------------------------------
/apps/website/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/apps/website/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'tailwindcss'
2 |
3 | const config = {
4 | darkMode: ['class'],
5 | content: [
6 | './pages/**/*.{ts,tsx}',
7 | './components/**/*.{ts,tsx}',
8 | './app/**/*.{ts,tsx}',
9 | './src/**/*.{ts,tsx}',
10 | ],
11 | prefix: '',
12 | theme: {
13 | container: {
14 | center: true,
15 | padding: '2rem',
16 | screens: {
17 | '2xl': '1400px',
18 | },
19 | },
20 | extend: {
21 | transitionDelay: {
22 | 1500: '1500ms',
23 | },
24 | colors: {
25 | border: 'hsl(var(--border))',
26 | input: 'hsl(var(--input))',
27 | ring: 'hsl(var(--ring))',
28 | background: 'hsl(var(--background))',
29 | foreground: 'hsl(var(--foreground))',
30 | primary: {
31 | DEFAULT: 'hsl(var(--primary))',
32 | foreground: 'hsl(var(--primary-foreground))',
33 | },
34 | secondary: {
35 | DEFAULT: 'hsl(var(--secondary))',
36 | foreground: 'hsl(var(--secondary-foreground))',
37 | },
38 | destructive: {
39 | DEFAULT: 'hsl(var(--destructive))',
40 | foreground: 'hsl(var(--destructive-foreground))',
41 | },
42 | muted: {
43 | DEFAULT: 'hsl(var(--muted))',
44 | foreground: 'hsl(var(--muted-foreground))',
45 | },
46 | accent: {
47 | DEFAULT: 'hsl(var(--accent))',
48 | foreground: 'hsl(var(--accent-foreground))',
49 | },
50 | popover: {
51 | DEFAULT: 'hsl(var(--popover))',
52 | foreground: 'hsl(var(--popover-foreground))',
53 | },
54 | card: {
55 | DEFAULT: 'hsl(var(--card))',
56 | foreground: 'hsl(var(--card-foreground))',
57 | },
58 | },
59 | borderRadius: {
60 | lg: 'var(--radius)',
61 | md: 'calc(var(--radius) - 2px)',
62 | sm: 'calc(var(--radius) - 4px)',
63 | },
64 | keyframes: {
65 | 'accordion-down': {
66 | from: { height: '0' },
67 | to: { height: 'var(--radix-accordion-content-height)' },
68 | },
69 | 'accordion-up': {
70 | from: { height: 'var(--radix-accordion-content-height)' },
71 | to: { height: '0' },
72 | },
73 |
74 | 'caret-blink': {
75 | '0%,70%,100%': {
76 | opacity: '1',
77 | },
78 | '20%,50%': {
79 | opacity: '0',
80 | },
81 | },
82 |
83 | 'fade-in': {
84 | from: {
85 | opacity: '0',
86 | },
87 | to: { opacity: '1' },
88 | },
89 | 'fade-up': {
90 | from: {
91 | opacity: '0',
92 | transform: 'translateY(var(--fade-distance, .25rem))',
93 | },
94 | to: { opacity: '1', transform: 'translateY(0)' },
95 | },
96 | },
97 | animation: {
98 | 'accordion-down': 'accordion-down 0.2s ease-out',
99 | 'accordion-up': 'accordion-up 0.2s ease-out',
100 |
101 | 'caret-blink': 'caret-blink 1.2s ease-out infinite',
102 | 'fade-in': 'fade-in 0.3s ease-out forwards',
103 | 'fade-up': 'fade-up 1s ease-out forwards',
104 | },
105 | },
106 | },
107 | plugins: [require('tailwindcss-animate')],
108 | } satisfies Config
109 |
110 | export default config
111 |
--------------------------------------------------------------------------------
/apps/website/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": [
4 | "dom",
5 | "dom.iterable",
6 | "esnext"
7 | ],
8 | "allowJs": true,
9 | "skipLibCheck": true,
10 | "strict": true,
11 | "noEmit": true,
12 | "esModuleInterop": true,
13 | "module": "esnext",
14 | "moduleResolution": "bundler",
15 | "resolveJsonModule": true,
16 | "isolatedModules": true,
17 | "jsx": "preserve",
18 | "incremental": true,
19 | "plugins": [
20 | {
21 | "name": "next"
22 | }
23 | ],
24 | "paths": {
25 | "@/*": [
26 | "./src/*"
27 | ]
28 | }
29 | },
30 | "include": [
31 | "next-env.d.ts",
32 | "**/*.ts",
33 | "**/*.tsx",
34 | "website/.next/types/**/*.ts",
35 | ".next/types/**/*.ts"
36 | ],
37 | "exclude": [
38 | "node_modules"
39 | ]
40 | }
41 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "root",
3 | "private": true,
4 | "author": "Guilherme Rodz ",
5 | "description": "One-time password input component for React.",
6 | "homepage": "https://input-otp.rodz.dev/",
7 | "workspaces": [
8 | "packages/*",
9 | "apps/*"
10 | ],
11 | "scripts": {
12 | "build": "turbo run build --filter=*",
13 | "build:lib": "turbo run build --filter=input-otp...",
14 | "build:website": "turbo run build --filter=website...",
15 | "clean": "turbo run clean",
16 | "dev": "turbo run dev --filter=*",
17 | "dev:website": "turbo run dev --filter=website...",
18 | "dev:test": "turbo run dev --filter=test...",
19 | "start:website": "pnpm --filter=website start",
20 | "storybook": "turbo run storybook",
21 | "test": "turbo run test --filter=test...",
22 | "test:ui": "turbo run test:ui --filter=test...",
23 | "type-check": "turbo run type-check",
24 | "lint:lib": "turbo run lint --filter=input-otp",
25 | "format": "prettier --write .",
26 | "release": "run-s test build:lib && cd ./packages/input-otp && cp ../../README.md . && pnpm release && rimraf ./README.md && cd ../..",
27 | "release:bypass": "run-s build:lib && cd ./packages/input-otp && cp ../../README.md . && pnpm release && rimraf ./README.md && cd ../..",
28 | "release:beta": "run-s test build:lib && cd ./packages/input-otp && cp ../../README.md . && pnpm release:beta && rimraf ./README.md && cd ../..",
29 | "release:beta:bypass": "run-s build:lib && cd ./packages/input-otp && cp ../../README.md . && pnpm release:beta && rimraf ./README.md && cd ../.."
30 | },
31 | "prettier": {
32 | "tabWidth": 2,
33 | "printWidth": 80,
34 | "useTabs": false,
35 | "semi": false,
36 | "singleQuote": true,
37 | "trailingComma": "all",
38 | "arrowParens": "avoid",
39 | "endOfLine": "lf"
40 | },
41 | "trustedDependencies": [
42 | "npm-run-all"
43 | ],
44 | "devDependencies": {
45 | "@playwright/test": "^1.41.2",
46 | "@types/node": "^20",
47 | "@typescript-eslint/eslint-plugin": "^7.0.2",
48 | "@typescript-eslint/parser": "^7.0.2",
49 | "eslint-config-prettier": "^9.1.0",
50 | "eslint-plugin-import": "^2.29.1",
51 | "eslint-plugin-react": "^7.33.2",
52 | "eslint-plugin-react-hooks": "^4.6.0",
53 | "npm-run-all": "^4.1.5",
54 | "prettier": "^3.2.5",
55 | "rimraf": "^5.0.5",
56 | "tsup": "^8.0.2",
57 | "turbo": "^1.12.4",
58 | "typescript": "^5.3.3"
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/packages/input-otp/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "input-otp",
3 | "version": "1.2.4",
4 | "author": "Guilherme Rodz ",
5 | "description": "One-time password input component for React.",
6 | "license": "MIT",
7 | "homepage": "https://input-otp.rodz.dev/",
8 | "repository": {
9 | "type": "git",
10 | "url": "git+https://github.com/guilhermerodz/input-otp.git",
11 | "directory": "packages/input-otp"
12 | },
13 | "bugs": {
14 | "url": "https://github.com/guilhermerodz/input-otp/issues"
15 | },
16 | "keywords": [
17 | "react",
18 | "otp",
19 | "input",
20 | "accessible"
21 | ],
22 | "main": "./dist/index.js",
23 | "module": "./dist/index.mjs",
24 | "types": "./dist/index.d.ts",
25 | "files": [
26 | "dist"
27 | ],
28 | "exports": {
29 | ".": {
30 | "types": "./dist/index.d.ts",
31 | "module": "./dist/index.mjs",
32 | "import": "./dist/index.mjs",
33 | "require": "./dist/index.js",
34 | "default": "./dist/index.mjs"
35 | },
36 | "./package.json": "./package.json"
37 | },
38 | "scripts": {
39 | "type-check": "tsc --noEmit",
40 | "copy-readme": "cp ../../README.md ./README.md",
41 | "build": "run-s build:* copy-readme",
42 | "build:tsup": "tsup --dts --minify",
43 | "clean": "rimraf dist",
44 | "dev": "tsup --watch --dts",
45 | "lint": "run-p lint:*",
46 | "lint:eslint": "eslint src --ext .ts",
47 | "lint:eslint:fix": "eslint src --ext .ts --fix",
48 | "lint:format": "prettier --check \"src/**/*.ts\"",
49 | "lint:format:fix": "prettier --check \"src/**/*.ts\" --write",
50 | "lint:tsc": "tsc --project tsconfig.json --noEmit",
51 | "format": "prettier --write .",
52 | "release": "npm publish",
53 | "release:beta": "npm publish --tag beta"
54 | },
55 | "eslintConfig": {
56 | "root": true,
57 | "reportUnusedDisableDirectives": true,
58 | "ignorePatterns": [
59 | "**/build",
60 | "**/coverage",
61 | "**/dist"
62 | ],
63 | "parser": "@typescript-eslint/parser",
64 | "parserOptions": {
65 | "sourceType": "module",
66 | "ecmaVersion": 2020
67 | },
68 | "settings": {
69 | "import/parsers": {
70 | "@typescript-eslint/parser": [
71 | ".ts",
72 | ".tsx"
73 | ]
74 | },
75 | "import/resolver": {
76 | "typescript": true
77 | },
78 | "react": {
79 | "version": "detect"
80 | }
81 | },
82 | "plugins": [
83 | "@typescript-eslint",
84 | "import"
85 | ],
86 | "extends": [
87 | "plugin:react/jsx-runtime",
88 | "plugin:react-hooks/recommended",
89 | "eslint:recommended",
90 | "plugin:@typescript-eslint/recommended",
91 | "plugin:@typescript-eslint/stylistic",
92 | "plugin:import/recommended",
93 | "plugin:import/typescript",
94 | "prettier"
95 | ],
96 | "env": {
97 | "browser": true,
98 | "es2020": true
99 | },
100 | "rules": {
101 | "react/jsx-key": [
102 | "error",
103 | {
104 | "checkFragmentShorthand": true
105 | }
106 | ],
107 | "react-hooks/exhaustive-deps": "error",
108 | "@typescript-eslint/no-explicit-any": "off"
109 | }
110 | },
111 | "devDependencies": {
112 | "@types/react": "^18.2.55",
113 | "@types/react-dom": "^18.2.19",
114 | "react": "^18.2.0",
115 | "react-dom": "^18.2.0",
116 | "typescript": "^5.3.3"
117 | },
118 | "peerDependencies": {
119 | "react": "^16.8 || ^17.0 || ^18.0",
120 | "react-dom": "^16.8 || ^17.0 || ^18.0"
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/packages/input-otp/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './input'
2 | export * from './regexp'
3 | export { OTPInputProps, SlotProps, RenderProps } from './types'
4 |
--------------------------------------------------------------------------------
/packages/input-otp/src/regexp.ts:
--------------------------------------------------------------------------------
1 | export const REGEXP_ONLY_DIGITS = '^\\d+$'
2 | export const REGEXP_ONLY_CHARS = '^[a-zA-Z]+$'
3 | export const REGEXP_ONLY_DIGITS_AND_CHARS = '^[a-zA-Z0-9]+$'
4 |
--------------------------------------------------------------------------------
/packages/input-otp/src/sync-timeouts.ts:
--------------------------------------------------------------------------------
1 | export function syncTimeouts(cb: (...args: any[]) => unknown): number[] {
2 | const t1 = setTimeout(cb, 0) // For faster machines
3 | const t2 = setTimeout(cb, 1_0)
4 | const t3 = setTimeout(cb, 5_0)
5 | return [t1, t2, t3]
6 | }
7 |
--------------------------------------------------------------------------------
/packages/input-otp/src/types.ts:
--------------------------------------------------------------------------------
1 | export interface SlotProps {
2 | isActive: boolean
3 | char: string | null
4 | hasFakeCaret: boolean
5 | }
6 | export interface RenderProps {
7 | slots: SlotProps[]
8 | isFocused: boolean
9 | isHovering: boolean
10 | }
11 | type OverrideProps = Omit & R
12 | type OTPInputBaseProps = OverrideProps<
13 | React.InputHTMLAttributes,
14 | {
15 | value?: string
16 | onChange?: (newValue: string) => unknown
17 |
18 | maxLength: number
19 |
20 | textAlign?: 'left' | 'center' | 'right'
21 |
22 | onComplete?: (...args: any[]) => unknown
23 | pushPasswordManagerStrategy?: 'increase-width' | 'none'
24 |
25 | containerClassName?: string
26 |
27 | noScriptCSSFallback?: string | null
28 | }
29 | >
30 | type InputOTPRenderFn = (props: RenderProps) => React.ReactNode
31 | export type OTPInputProps = OTPInputBaseProps &
32 | (
33 | | {
34 | render: InputOTPRenderFn
35 | children?: never
36 | }
37 | | {
38 | render?: never
39 | children: React.ReactNode
40 | }
41 | )
42 |
--------------------------------------------------------------------------------
/packages/input-otp/src/use-previous.ts:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | export function usePrevious(value: T) {
4 | const ref = React.useRef()
5 | React.useEffect(() => {
6 | ref.current = value
7 | })
8 | return ref.current
9 | }
10 |
--------------------------------------------------------------------------------
/packages/input-otp/src/use-pwm-badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { OTPInputProps } from './types'
3 |
4 | const PWM_BADGE_MARGIN_RIGHT = 18
5 | const PWM_BADGE_SPACE_WIDTH_PX = 40
6 | const PWM_BADGE_SPACE_WIDTH = `${PWM_BADGE_SPACE_WIDTH_PX}px` as const
7 |
8 | const PASSWORD_MANAGERS_SELECTORS = [
9 | '[data-lastpass-icon-root]', // LastPass
10 | 'com-1password-button', // 1Password
11 | '[data-dashlanecreated]', // Dashlane
12 | '[style$="2147483647 !important;"]', // Bitwarden
13 | ].join(',')
14 |
15 | export function usePasswordManagerBadge({
16 | containerRef,
17 | inputRef,
18 | pushPasswordManagerStrategy,
19 | isFocused,
20 | }: {
21 | containerRef: React.RefObject
22 | inputRef: React.RefObject
23 | pushPasswordManagerStrategy: OTPInputProps['pushPasswordManagerStrategy']
24 | isFocused: boolean
25 | }) {
26 | // Metadata for instant updates (not React state)
27 | const pwmMetadata = React.useRef<{
28 | done: boolean
29 | refocused: boolean
30 | }>({
31 | done: false,
32 | refocused: false,
33 | })
34 |
35 | /** Password managers have a badge
36 | * and I'll use this state to push them
37 | * outside the input */
38 | const [hasPWMBadge, setHasPWMBadge] = React.useState(false)
39 | const [hasPWMBadgeSpace, setHasPWMBadgeSpace] = React.useState(false)
40 | const [done, setDone] = React.useState(false)
41 |
42 | const willPushPWMBadge = React.useMemo(() => {
43 | if (pushPasswordManagerStrategy === 'none') {
44 | return false
45 | }
46 |
47 | const increaseWidthCase =
48 | (pushPasswordManagerStrategy === 'increase-width' ||
49 | // TODO: remove 'experimental-no-flickering' support in 2.0.0
50 | pushPasswordManagerStrategy === 'experimental-no-flickering') &&
51 | hasPWMBadge &&
52 | hasPWMBadgeSpace
53 |
54 | return increaseWidthCase
55 | }, [hasPWMBadge, hasPWMBadgeSpace, pushPasswordManagerStrategy])
56 |
57 | const trackPWMBadge = React.useCallback(() => {
58 | const container = containerRef.current
59 | const input = inputRef.current
60 | if (
61 | !container ||
62 | !input ||
63 | done ||
64 | pushPasswordManagerStrategy === 'none'
65 | ) {
66 | return
67 | }
68 |
69 | const elementToCompare = container
70 |
71 | // Get the top right-center point of the container.
72 | // That is usually where most password managers place their badge.
73 | const rightCornerX =
74 | elementToCompare.getBoundingClientRect().left +
75 | elementToCompare.offsetWidth
76 | const centereredY =
77 | elementToCompare.getBoundingClientRect().top +
78 | elementToCompare.offsetHeight / 2
79 | const x = rightCornerX - PWM_BADGE_MARGIN_RIGHT
80 | const y = centereredY
81 |
82 | // Do an extra search to check for famous password managers
83 | const pmws = document.querySelectorAll(PASSWORD_MANAGERS_SELECTORS)
84 |
85 | // If no password manager is automatically detect,
86 | // we'll try to dispatch document.elementFromPoint
87 | // to identify badges
88 | if (pmws.length === 0) {
89 | const maybeBadgeEl = document.elementFromPoint(x, y)
90 |
91 | // If the found element is the input itself,
92 | // then we assume it's not a password manager badge.
93 | // We are not sure. Most times that means there isn't a badge.
94 | if (maybeBadgeEl === container) {
95 | return
96 | }
97 | }
98 |
99 | setHasPWMBadge(true)
100 | setDone(true)
101 |
102 | // For specific password managers,
103 | // the input has to be re-focused
104 | // to trigger a re-position of the badge.
105 | if (!pwmMetadata.current.refocused && document.activeElement === input) {
106 | const sel = [input.selectionStart, input.selectionEnd]
107 | input.blur()
108 | input.focus()
109 | // Recover the previous selection
110 | input.setSelectionRange(sel[0], sel[1])
111 |
112 | pwmMetadata.current.refocused = true
113 | }
114 | }, [containerRef, inputRef, done, pushPasswordManagerStrategy])
115 |
116 | React.useEffect(() => {
117 | const container = containerRef.current
118 | if (!container || pushPasswordManagerStrategy === 'none') {
119 | return
120 | }
121 |
122 | // Check if the PWM area is 100% visible
123 | function checkHasSpace() {
124 | const viewportWidth = window.innerWidth
125 | const distanceToRightEdge =
126 | viewportWidth - container.getBoundingClientRect().right
127 | setHasPWMBadgeSpace(distanceToRightEdge >= PWM_BADGE_SPACE_WIDTH_PX)
128 | }
129 |
130 | checkHasSpace()
131 | const interval = setInterval(checkHasSpace, 1000)
132 |
133 | return () => {
134 | clearInterval(interval)
135 | }
136 | }, [containerRef, pushPasswordManagerStrategy])
137 |
138 | React.useEffect(() => {
139 | const _isFocused = isFocused || document.activeElement === inputRef.current
140 |
141 | if (pushPasswordManagerStrategy === 'none' || !_isFocused) {
142 | return
143 | }
144 | const t1 = setTimeout(trackPWMBadge, 0)
145 | const t2 = setTimeout(trackPWMBadge, 2000)
146 | const t3 = setTimeout(trackPWMBadge, 5000)
147 | const t4 = setTimeout(() => {
148 | setDone(true)
149 | }, 6000)
150 | return () => {
151 | clearTimeout(t1)
152 | clearTimeout(t2)
153 | clearTimeout(t3)
154 | clearTimeout(t4)
155 | }
156 | }, [inputRef, isFocused, pushPasswordManagerStrategy, trackPWMBadge])
157 |
158 | return { hasPWMBadge, willPushPWMBadge, PWM_BADGE_SPACE_WIDTH }
159 | }
160 |
--------------------------------------------------------------------------------
/packages/input-otp/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "jsx": "react",
4 | "target": "es2015",
5 | "moduleResolution": "node",
6 | "esModuleInterop": true,
7 | "lib": ["es2015", "dom"],
8 | "allowUnreachableCode": false,
9 | "allowUnusedLabels": false,
10 | "removeComments": false
11 | },
12 | "include": ["src"]
13 | }
14 |
--------------------------------------------------------------------------------
/packages/input-otp/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'tsup';
2 |
3 | export default defineConfig({
4 | name: 'save input-otp',
5 | entry: ['src/index.ts'],
6 | format: ['cjs', 'esm'],
7 | outDir: 'dist',
8 | clean: true,
9 | sourcemap: true,
10 | });
11 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - "apps/*"
3 | - "packages/*"
4 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["//"],
3 | "$schema": "https://turbo.build/schema.json",
4 | "pipeline": {
5 | "build": {
6 | "dependsOn": ["^build"],
7 | "outputs": ["dist/**", ".next/**"]
8 | },
9 | "clean": {
10 | "cache": false
11 | },
12 | "dev": {
13 | "cache": false
14 | },
15 | "storybook": {
16 | "cache": false
17 | },
18 | "test#test": {
19 | "dependsOn": ["input-otp#build"],
20 | "cache": false
21 | },
22 | "test:ui": {
23 | "dependsOn": ["input-otp#build"],
24 | "cache": false
25 | },
26 | "type-check": {
27 | "cache": false
28 | },
29 | "lint": {
30 | "cache": false
31 | },
32 | "format": {
33 | "cache": false
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------