itc-otp-verification
v1.0.0
Published
A modern, customizable OTP (One-Time Password) verification component for Vue 3 + Quasar with auto-navigation, paste support, and timer functionality
Downloads
97
Maintainers
Readme
OTP Verification Component
A modern, customizable OTP (One-Time Password) verification dialog component for Vue 3 + Quasar applications with automatic field navigation, paste support, and countdown timer.
Features
- ✅ Auto-focus & Navigation - Automatically focuses first field and moves between fields
- ✅ Keyboard Support - Arrow keys, backspace navigation
- ✅ Paste Support - Paste OTP from clipboard
- ✅ Countdown Timer - Shows expiration time with resend option
- ✅ Customizable Length - Support for 4, 6, or any digit OTP
- ✅ Phone & Email - Supports both phone and email verification
- ✅ Responsive - Works on desktop and mobile
- ✅ Loading States - Built-in loading indicator for submit button
- ✅ v-model Support - Easy dialog visibility control
- ✅ TypeScript Ready - Full TypeScript support
Installation
npm install @itc/otp-verification
# or
pnpm install @itc/otp-verificationRequirements
- Vue 3.x
- Quasar 2.x
These are peer dependencies and must be installed in your project.
Usage
Basic Example
<script setup>
import { ref } from 'vue'
import { OtpVerification } from '@itc/otp-verification'
const showOtp = ref(false)
const contactInfo = ref({
country_code: '+1',
phone_mobile: '5551234567'
})
const otpTimer = ref('02:00')
const loading = ref(false)
const handleSubmit = (otp) => {
console.log('OTP entered:', otp)
loading.value = true
// Your verification API call here
}
const handleResend = () => {
console.log('Resend OTP')
otpTimer.value = '02:00'
// Your resend OTP API call here
}
</script>
<template>
<div>
<q-btn label="Verify Phone" @click="showOtp = true" />
<OtpVerification
v-model="showOtp"
:contact-info="contactInfo"
contact-type="phone"
:otp-expire-counter="otpTimer"
:loading="loading"
@submit-otp="handleSubmit"
@resend-otp="handleResend"
/>
</div>
</template>Email Verification
<script setup>
import { ref } from 'vue'
import { OtpVerification } from '@itc/otp-verification'
const showOtp = ref(false)
const contactInfo = ref({
email: '[email protected]'
})
const otpTimer = ref('03:00')
const handleSubmit = (otp) => {
// Verify email OTP
console.log('Email OTP:', otp)
}
</script>
<template>
<OtpVerification
v-model="showOtp"
:contact-info="contactInfo"
contact-type="email"
:otp-expire-counter="otpTimer"
:otp-length="6"
@submit-otp="handleSubmit"
@resend-otp="handleResend"
/>
</template>With Timer Management
<script setup>
import { ref, onUnmounted } from 'vue'
import { OtpVerification } from '@itc/otp-verification'
const showOtp = ref(false)
const contactInfo = ref({
country_code: '+1',
phone_mobile: '5551234567'
})
const otpTimer = ref('02:00')
const loading = ref(false)
let timerInterval = null
const startTimer = () => {
let seconds = 120 // 2 minutes
timerInterval = setInterval(() => {
if (seconds <= 0) {
clearInterval(timerInterval)
otpTimer.value = '00:00'
return
}
seconds--
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
otpTimer.value = `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
}, 1000)
}
const handleSubmit = async (otp) => {
loading.value = true
try {
const response = await fetch('/api/verify-otp', {
method: 'POST',
body: JSON.stringify({ otp, phone: contactInfo.value.phone_mobile })
})
if (response.ok) {
showOtp.value = false
console.log('OTP verified successfully')
}
} finally {
loading.value = false
}
}
const handleResend = async () => {
await fetch('/api/resend-otp', {
method: 'POST',
body: JSON.stringify({ phone: contactInfo.value.phone_mobile })
})
otpTimer.value = '02:00'
startTimer()
}
const openOtpDialog = () => {
showOtp.value = true
startTimer()
}
onUnmounted(() => {
if (timerInterval) clearInterval(timerInterval)
})
</script>
<template>
<div>
<q-btn label="Verify Phone" @click="openOtpDialog" />
<OtpVerification
v-model="showOtp"
:contact-info="contactInfo"
contact-type="phone"
:otp-expire-counter="otpTimer"
:loading="loading"
@submit-otp="handleSubmit"
@resend-otp="handleResend"
@close="showOtp = false"
/>
</div>
</template>Props
| Prop | Type | Default | Required | Description |
|------|------|---------|----------|-------------|
| modelValue | Boolean | false | Yes | Controls dialog visibility (v-model) |
| contactInfo | Object | {} | Yes | Contact information object |
| contactType | String | 'phone' | No | Type of contact: 'phone' or 'email' |
| otpLength | Number | 4 | No | Number of OTP digits (4, 6, etc.) |
| otpExpireCounter | String\|Number | 0 | Yes | Timer display (e.g., '02:00') |
| loading | Boolean | false | No | Loading state for submit button |
| dialogTitle | String | 'OTP VERIFICATION' | No | Dialog header title |
| continueLabel | String | 'Continue' | No | Submit button label |
| persistent | Boolean | true | No | Prevents closing dialog on outside click |
contactInfo Object Structure
For phone verification:
{
country_code: '+1', // Optional
phone_mobile: '5551234567' // Required
// or
phone: '5551234567' // Alternative to phone_mobile
}For email verification:
{
email: '[email protected]' // Required
}Events
| Event | Payload | Description |
|-------|---------|-------------|
| update:modelValue | Boolean | Emitted when dialog visibility changes |
| submit-otp | String | Emitted with OTP value when submit button is clicked |
| resend-otp | - | Emitted when "Resend OTP" link is clicked |
| close | - | Emitted when dialog is closed |
Features in Detail
Auto-Navigation
The component automatically moves focus to the next field when a digit is entered, and to the previous field when backspace is pressed on an empty field.
Paste Support
Users can paste the entire OTP code, and it will be automatically distributed across all input fields.
Keyboard Navigation
- Arrow Left/Right: Navigate between fields
- Backspace: Clear current field and move to previous
- Delete: Clear current field
Timer Display
Shows a countdown timer with the format MM:SS. When the timer reaches 00:00, the "Resend OTP" link appears automatically.
Responsive Design
The component adapts its input field width based on screen size:
- Desktop: 6 characters width
- Mobile: 4 characters width
Submit Button State
The submit button is automatically disabled until all OTP fields are filled.
Customization
Custom Styling
You can pass additional Quasar dialog props using v-bind="$attrs":
<OtpVerification
v-model="showOtp"
:contact-info="contactInfo"
:otp-expire-counter="otpTimer"
maximized
transition-show="slide-up"
transition-hide="slide-down"
@submit-otp="handleSubmit"
/>Custom Labels
<OtpVerification
v-model="showOtp"
:contact-info="contactInfo"
:otp-expire-counter="otpTimer"
dialog-title="Verify Your Phone"
continue-label="Verify"
@submit-otp="handleSubmit"
/>Browser Support
- Chrome/Edge (latest)
- Firefox (latest)
- Safari (latest)
- Mobile browsers (iOS Safari, Chrome Mobile)
License
MIT
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
Support
For issues or questions, please visit: GitHub Issues
Installation
npm install company-branding
# or
pnpm install company-brandingUsage
OtpVerification Component
A complete OTP verification dialog with field management, timer, and resend functionality.
Basic Usage
<script setup>
import { ref } from 'vue'
import { OtpVerification } from 'company-branding'
const showOtp = ref(false)
const contactInfo = ref({
country_code: '+1',
phone_mobile: '1234567890'
})
const otpTimer = ref('02:00')
const loading = ref(false)
const handleSubmit = (otp) => {
console.log('OTP entered:', otp)
loading.value = true
// Your verification logic here
}
const handleResend = () => {
// Your resend OTP logic here
otpTimer.value = '02:00'
}
</script>
<template>
<OtpVerification
v-model="showOtp"
:contact-info="contactInfo"
contact-type="phone"
:otp-expire-counter="otpTimer"
:loading="loading"
@submit-otp="handleSubmit"
@resend-otp="handleResend"
@close="showOtp = false"
/>
</template>With Email Contact
<script setup>
import { ref } from 'vue'
import { OtpVerification } from 'company-branding'
const showOtp = ref(false)
const contactInfo = ref({
email: '[email protected]'
})
const otpTimer = ref('03:00')
const handleSubmit = (otp) => {
// Verify OTP
console.log('OTP:', otp)
}
</script>
<template>
<OtpVerification
v-model="showOtp"
:contact-info="contactInfo"
contact-type="email"
:otp-expire-counter="otpTimer"
:otp-length="6"
@submit-otp="handleSubmit"
@resend-otp="handleResend"
/>
</template>OtpVerification Props
| Prop | Type | Default | Required | Description |
|------|------|---------|----------|-------------|
| modelValue | Boolean | false | Yes | Controls dialog visibility (v-model) |
| contactInfo | Object | {} | Yes | Contact information object |
| contactType | String | 'phone' | No | Type of contact: 'phone' or 'email' |
| otpLength | Number | 4 | No | Number of OTP digits |
| otpExpireCounter | String\|Number | 0 | Yes | Timer display (e.g., '02:00') |
| loading | Boolean | false | No | Loading state for submit button |
| dialogTitle | String | 'OTP VERIFICATION' | No | Dialog header title |
| continueLabel | String | 'Continue' | No | Submit button label |
| persistent | Boolean | true | No | Prevents closing on click outside |
contactInfo Object Structure
For phone:
{
country_code: '+1', // Optional
phone_mobile: '1234567890', // Required
// or
phone: '1234567890' // Alternative to phone_mobile
}For email:
{
email: '[email protected]' // Required
}OtpVerification Events
| Event | Payload | Description |
|-------|---------|-------------|
| update:modelValue | Boolean | Emitted when dialog visibility changes |
| submit-otp | String | Emitted with OTP value when submit clicked |
| resend-otp | - | Emitted when resend OTP is clicked |
| close | - | Emitted when dialog is closed |
Features
- ✅ Auto-focus - First field is auto-focused
- ✅ Auto-navigation - Automatically moves to next field on input
- ✅ Arrow key navigation - Use arrow keys to move between fields
- ✅ Backspace support - Moves to previous field on backspace
- ✅ Paste support - Paste OTP from clipboard
- ✅ Timer display - Shows countdown with resend option
- ✅ Responsive - Adapts to mobile and desktop
- ✅ Validation - Submit button disabled until all fields filled
CompanyBranding Component
A dynamic logo component that loads brand-specific logos based on domain names.
Basic Usage
<script setup>
import { CompanyBranding } from 'company-branding'
</script>
<template>
<CompanyBranding />
</template>With Custom Fallback
⚠️ IMPORTANT: You MUST import images from your assets:
<script setup>
import { CompanyBranding } from 'company-branding'
import logoFallback from '@/assets/logos/logo-horizontal.png'
</script>
<template>
<CompanyBranding :fallback="logoFallback" />
</template>With Additional Props
<template>
<CompanyBranding
:fallback="logoFallback"
height="80px"
width="200px"
fit="contain"
class="my-logo"
/>
</template>How It Works
The component automatically:
- Reads
window.location.hostname(e.g.,dashboard.acme.com) - Extracts the company name (second-to-last part:
acme) - Attempts to load
/logo-horizontal-acme.png - Falls back to provided fallback if not found
Domain Examples:
dashboard.acme.com→/logo-horizontal-acme.pngportal.techcorp.io→/logo-horizontal-techcorp.pnglocalhost→ Uses fallback
CompanyBranding Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| fallback | String | Built-in SVG placeholder | Fallback image URL (must be imported) |
All other props are passed through to q-img.
Logo Naming Convention
Place logos in your app's public folder:
your-app/
└── public/
├── logo-horizontal-acme.png
├── logo-horizontal-techcorp.png
└── logo-horizontal-clientname.pngComplete Example
<script setup>
import { ref, onMounted } from 'vue'
import { OtpVerification, CompanyBranding } from 'company-branding'
import defaultLogo from '@/assets/logos/logo-horizontal.png'
const showOtp = ref(false)
const contactInfo = ref({
country_code: '+1',
phone_mobile: '5551234567'
})
const otpTimer = ref('02:00')
const loading = ref(false)
let timerInterval = null
const startTimer = () => {
let seconds = 120
timerInterval = setInterval(() => {
if (seconds <= 0) {
clearInterval(timerInterval)
otpTimer.value = '00:00'
return
}
seconds--
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
otpTimer.value = `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
}, 1000)
}
const handleSubmit = async (otp) => {
loading.value = true
try {
// Your API call to verify OTP
const response = await fetch('/api/verify-otp', {
method: 'POST',
body: JSON.stringify({ otp, phone: contactInfo.value.phone_mobile })
})
if (response.ok) {
showOtp.value = false
// Success
}
} finally {
loading.value = false
}
}
const handleResend = async () => {
// Your API call to resend OTP
await fetch('/api/resend-otp', {
method: 'POST',
body: JSON.stringify({ phone: contactInfo.value.phone_mobile })
})
otpTimer.value = '02:00'
startTimer()
}
const openOtpDialog = () => {
showOtp.value = true
startTimer()
}
onMounted(() => {
// Cleanup on unmount
return () => {
if (timerInterval) clearInterval(timerInterval)
}
})
</script>
<template>
<div class="app">
<header class="app-header">
<CompanyBranding
:fallback="defaultLogo"
height="60px"
width="200px"
class="header-logo"
/>
</header>
<main>
<q-btn label="Verify Phone" @click="openOtpDialog" />
</main>
<OtpVerification
v-model="showOtp"
:contact-info="contactInfo"
contact-type="phone"
:otp-expire-counter="otpTimer"
:otp-length="4"
:loading="loading"
@submit-otp="handleSubmit"
@resend-otp="handleResend"
@close="showOtp = false"
/>
</div>
</template>Requirements
- Vue 3.x
- Quasar 2.x
These are peer dependencies and must be installed in your project.
License
MIT
Getting Started
Prerequisites
- Node.js 20.19+ or 22.12+
- pnpm (recommended) or npm/yarn
Installation
Clone or use this template:
git clone <your-repo-url> cd <your-project-name>Install dependencies:
pnpm install # or npm installUpdate package.json:
- Change
nameto your package name - Update
version,description,author,license, andkeywords - Adjust
dependenciesanddevDependenciesas needed
- Change
Update src/index.ts:
- Export your main components/utilities
- Example:
export { default as MyComponent } from "./components/MyComponent.vue"; export * from "./utils/helpers";
Usage
Path Aliases
This template supports importing files using the src/ prefix without relative paths:
// ✅ Good - Using path alias
import { API_BASE_URL } from "src/consts";
import MyComponent from "src/components/MyComponent.vue";
import { helper } from "src/utils/helpers";
// ❌ Avoid - Relative paths (still works, but not recommended)
import { API_BASE_URL } from "../consts";
import MyComponent from "./components/MyComponent.vue";Adding More Path Aliases
To add additional path aliases (e.g., @/, @components/, etc.):
Update
tsconfig.json:{ "compilerOptions": { "baseUrl": ".", "paths": { "src/*": ["./src/*"], "@/*": ["./src/*"], "@components/*": ["./src/components/*"], "@utils/*": ["./src/utils/*"] } } }Update
vite.config.ts:resolve: { alias: { 'src': resolve(__dirname, './src'), '@': resolve(__dirname, './src'), '@components': resolve(__dirname, './src/components'), '@utils': resolve(__dirname, './src/utils') } }The
vite-tsconfig-pathsplugin will automatically read fromtsconfig.json, but explicit aliases in Vite config ensure build-time resolution works correctly.Restart your TypeScript server (in VS Code/Cursor:
Cmd+Shift+P→ "TypeScript: Restart TS Server")
Project Structure
├─ src/
│ ├─ components/ # Vue components
│ │ └─ ExampleComponent.vue
│ ├─ utils/ # Utility functions (optional)
│ ├─ types/ # TypeScript type definitions (optional)
│ ├─ consts.ts # Constants
│ ├─ index.ts # Main export file
│ └─ vue-shim.d.ts # Vue type declarations
├─ dist/ # Build output (generated)
├─ package.json
├─ tsconfig.json
└─ vite.config.tsBuilding
Build for Production
pnpm run build
# or
npm run buildThis will:
- Compile TypeScript to JavaScript
- Bundle Vue components
- Generate both ES Modules (
.js) and CommonJS (.cjs) formats - Output to
dist/directory with preservedsrc/structure
Build Output Structure
dist/
└─ src/
├─ index.js # ES Module entry
├─ index.cjs # CommonJS entry
├─ components/
│ └─ ...
└─ ...Testing Locally
Option 1: Using pnpm link (Recommended)
In your plugin directory:
pnpm link --globalIn your test project:
pnpm link --global <your-package-name>Use in your project:
<script setup> import { ExampleComponent } from "<your-package-name>"; // or import { ExampleComponent } from "<your-package-name>/src/components/ExampleComponent.vue"; </script>
Option 2: Using File Path
In your test project's
package.json:{ "dependencies": { "<your-package-name>": "file:../path/to/your/plugin" } }Install:
pnpm installUse in your project:
<script setup> import { ExampleComponent } from "<your-package-name>"; </script>
Unlinking After Local Development
When you're done with local development and want to switch back to the published version (or remove the link):
For Option 1 (pnpm/npm link):
In your test project, unlink the package:
# For pnpm pnpm unlink --global <your-package-name> # For npm npm unlink <your-package-name>Reinstall the package from npm (or your registry):
pnpm install <your-package-name> # or npm install <your-package-name>Optionally, unlink from global (in your plugin directory):
# For pnpm pnpm unlink --global # For npm npm unlink -gNote: This step is optional. The global link can remain for future development sessions.
For Option 2 (File Path):
Remove the file path dependency from your test project's
package.json:{ "dependencies": { // Remove or comment out: // "<your-package-name>": "file:../path/to/your/plugin" } }Reinstall the package from npm:
pnpm install <your-package-name> # or npm install <your-package-name>
Troubleshooting Unlinking
If you encounter issues after unlinking:
Clear node_modules and reinstall:
rm -rf node_modules pnpm install # or npm installCheck for leftover symlinks:
# Check if symlink still exists ls -la node_modules/<your-package-name> # If it's still a symlink, remove it manually rm node_modules/<your-package-name> pnpm installVerify package source:
# Check where the package is coming from pnpm why <your-package-name> # or npm ls <your-package-name>
Development Workflow
For active development with auto-rebuild:
Run in watch mode:
pnpm run devChanges will rebuild automatically. Restart your test project's dev server to pick up changes.
Publishing to npm
Before Publishing
Update
package.json:- Set correct
name(must be unique on npm) - Update
version(follow semver) - Add
description,keywords,author,license - Verify
filesarray includes only what should be published:{ "files": ["dist"] }
- Set correct
Build the package:
pnpm run buildTest the build locally (see Testing Locally section above)
Publishing Steps
Login to npm:
npm loginVerify you're logged in:
npm whoamiCheck what will be published:
npm pack --dry-runPublish:
npm publishFor scoped packages (e.g.,
@your-org/package-name):npm publish --access publicVerify on npm: Visit
https://www.npmjs.com/package/<your-package-name>
Version Management
Use npm version commands to bump versions:
# Patch version (1.0.0 → 1.0.1)
npm version patch
# Minor version (1.0.0 → 1.1.0)
npm version minor
# Major version (1.0.0 → 2.0.0)
npm version majorThen publish:
npm publishConfiguration
External Dependencies
By default, vue and quasar are marked as external (not bundled). To add more:
Update vite.config.ts:
rollupOptions: {
external: (id) => {
return (
id === "vue" ||
id === "quasar" ||
id.startsWith("@quasar/") ||
id.startsWith("quasar/") ||
id === "some-other-package"
); // Add here
};
}Build Formats
The template builds both ES Modules and CommonJS. To change formats:
Update vite.config.ts:
build: {
lib: {
formats: ["es", "cjs"]; // or ['es'], ['cjs'], etc.
}
}Handling External Dependencies
Using Quasar Extras and Icon Sets
If your plugin requires dependencies like @quasar/extras, FontAwesome icons, or other Quasar-related packages that may not be present in the target project, you should declare them as peer dependencies rather than regular dependencies.
Why Peer Dependencies?
- Smaller bundle size: External dependencies aren't bundled into your package
- Version control: Consumers control which versions to install
- Avoid conflicts: Prevents duplicate packages in the consumer's project
- Best practice: Standard approach for library/plugin packages
Step 1: Add Peer Dependencies
Update your package.json to include peer dependencies:
{
"peerDependencies": {
"quasar": "^2.18.6",
"vue": "^3.5.25",
"@quasar/extras": "^1.0.0"
},
"peerDependenciesMeta": {
"@quasar/extras": {
"optional": true
}
}
}Notes:
peerDependencies: Required dependencies that consumers must installpeerDependenciesMeta: Mark dependencies as optional if they're not always needed- Keep
quasarandvueindependenciesfor development, but also list them inpeerDependenciesfor consumers
Step 2: Ensure Externalization in Build Config
Your vite.config.ts should already externalize @quasar/ packages (this is already configured):
rollupOptions: {
external: (id) => {
return (
id === "vue" ||
id === "quasar" ||
id.startsWith("@quasar/") || // ✅ Already covers @quasar/extras
id.startsWith("quasar/")
);
};
}If you need to externalize additional packages, add them to the external function:
rollupOptions: {
external: (id) => {
return (
id === "vue" ||
id === "quasar" ||
id.startsWith("@quasar/") ||
id.startsWith("quasar/") ||
id === "some-other-package" || // Add specific packages
id.startsWith("@some-scope/") // Or entire scopes
);
};
}Step 3: Document for Consumers
Add installation instructions to your README for consumers:
## Installation
```bash
npm install your-package @quasar/extras
# or
pnpm install your-package @quasar/extras
```Quasar Configuration
If you're using FontAwesome icons or other icon sets from @quasar/extras, configure them in your Quasar project:
For Quasar CLI projects (quasar.conf.js):
module.exports = function (ctx) {
return {
extras: [
"fontawesome-v6", // or 'fontawesome-v5', 'material-icons', etc.
"material-icons-outlined",
],
// ... rest of config
};
};For Vite projects (quasar.config.js):
export default {
extras: ["fontawesome-v6", "material-icons-outlined"],
// ... rest of config
};Step 4: Handle Missing Dependencies Gracefully (Optional)
If you want to provide fallbacks when dependencies aren't available, you can check for them:
<script setup>
import { computed } from "vue";
const props = defineProps({
icon: { type: String, default: "close" },
});
// Example: Fallback to Material Icons if FontAwesome not available
const safeIcon = computed(() => {
// If using FontAwesome icon (fa- prefix), ensure @quasar/extras is installed
if (props.icon?.startsWith("fa-") || props.icon?.startsWith("fas ")) {
// Document that @quasar/extras is required for FontAwesome icons
return props.icon;
}
return props.icon;
});
</script>Best Practices
- ✅ Use peer dependencies for packages that should be provided by the consumer
- ✅ Use regular dependencies only for packages that are internal to your plugin
- ✅ Document all peer dependencies in your README
- ✅ Use Material Icons by default (built into Quasar) when possible
- ✅ Only require
@quasar/extraswhen you specifically need FontAwesome, Material Symbols, etc. - ❌ Don't bundle large dependencies like icon sets into your package
- ❌ Don't assume consumers have specific Quasar extras installed
Troubleshooting
TypeScript Errors
- "Cannot find module 'src/...'": Restart TypeScript server
- "Cannot find module 'path'": Ensure
@types/nodeis installed - Path aliases not working: Check both
tsconfig.jsonandvite.config.tshave matching aliases
Build Errors
- "Rollup failed to resolve import": Add the package to
externalinvite.config.ts - "Preprocessor dependency not found": Install required preprocessors (e.g.,
sass-embeddedfor SCSS)
Import Errors in Test Project
- Ensure the package is built (
pnpm run build) - Check
package.jsonexports are correct - Verify the import path matches your exports
License
ISC (or update to your preferred license)
Contributing
- Fork the repository
- Create a feature branch
- Make your changes
- Build and test locally
- Submit a pull request
Happy coding! 🚀
