Scan with your phone to open the link
<script setup lang="ts">
import { ref } from 'vue'
import { FormField, Input, QRCode, Stack, Text } from '@hina-ui/vue'
const value = ref('https://hinaui.dev')
</script>
<template>
<Stack align="center" class="w-full max-w-sm" data-demo-qr-hero>
<QRCode :value="value" label="Hina UI documentation" />
<Text size="sm" tone="muted">Scan with your phone to open the link</Text>
<FormField label="QR code content" class="w-full">
<Input v-model="value" placeholder="https://hinaui.dev" />
</FormField>
</Stack>
</template>
Usage
<script setup lang="ts">
import { QRCode } from '@hina-ui/vue'
</script>
<template>
<QRCode value="https://hinaui.dev" label="Hina UI documentation" />
</template>
value is the original string to encode, including links, Unicode and plain text. The component neither visits links nor interprets their parameters. Use label to describe the purpose to assistive technology, and provide a separate accessible link when appropriate.
The complete SVG and its dimensions render during SSR. Hydration needs no measurement, canvas or QR image request. Empty strings show an empty state; data exceeding QR capacity shows an error and emits error. Correcting the value restores the code.
Examples
Size, colors and logo
Size includes the quiet zone. The square shrinks proportionally inside narrower containers. color and background accept CSS colors or tokens. The dedicated default tokens stay dark-on-light in both themes, without automatic inversion.
Use logo for a center image. Without an explicit level, codes with a logo use H; other codes use M. logoSize and logoMargin are pixels within the design size and scale with the code. The logo side is capped at a quarter of the total side. If the image fails to load, its backing is also removed, restoring the full code and emitting logo-error.
192px
<script setup lang="ts">
import { ref } from 'vue'
import {
FormField,
Inline,
QRCode,
SegmentedControl,
Slider,
Stack,
Switch,
Text,
type QRCodeLevel,
} from '@hina-ui/vue'
const size = ref(192)
const logo = ref(true)
const brand = ref(false)
const level = ref<QRCodeLevel>('H')
const levels = ['L', 'M', 'Q', 'H'].map(value => ({ value, label: value }))
</script>
<template>
<Stack align="center" class="w-full max-w-sm" data-demo-qr-appearance>
<QRCode
value="https://hinaui.dev"
:size="size"
:level="level"
:logo="logo ? '/favicon.png' : undefined"
:color="brand ? 'var(--color-brand-800)' : undefined"
/>
<Stack class="w-full">
<FormField label="Size">
<Inline :wrap="false" gap="sm">
<Slider v-model="size" :min="128" :max="256" :step="8" class="flex-1" />
<Text size="sm" tone="muted" class="w-14 shrink-0 tabular-nums">{{ size }}px</Text>
</Inline>
</FormField>
<FormField label="Error correction">
<SegmentedControl v-model="level" :options="levels" block />
</FormField>
<Inline justify="between">
<Switch v-model="logo">Center logo</Switch>
<Switch v-model="brand">Brand color</Switch>
</Inline>
</Stack>
</Stack>
</template>
Keep dark modules on a light background with sufficient contrast, use a small logo, and verify with actual scanning devices. A higher correction level cannot guarantee recovery from arbitrary obstruction. The default four-module margin follows the official QR Code quiet-zone requirement; it is not pixel padding.
Status and refresh
The application controls status. The component has no expiry timer, polling or request logic. Loading, expired and scanned states hide the old code while preserving its square. The expired-state button only emits refresh; the application updates value and status after its request.
Refresh simulates a request here. The application controls the status.
<script setup lang="ts">
import { onScopeDispose, ref } from 'vue'
import { QRCode, Select, Stack, Text, type QRCodeStatus } from '@hina-ui/vue'
const status = ref<QRCodeStatus>('expired')
const options = [
{ value: 'active', label: 'Active' },
{ value: 'loading', label: 'Loading' },
{ value: 'expired', label: 'Expired' },
{ value: 'scanned', label: 'Scanned' },
]
let timer: ReturnType<typeof setTimeout> | undefined
function refresh() {
clearTimeout(timer)
status.value = 'loading'
timer = setTimeout(() => {
status.value = 'active'
}, 800)
}
function change(value: unknown) {
clearTimeout(timer)
status.value = value as QRCodeStatus
}
onScopeDispose(() => clearTimeout(timer))
</script>
<template>
<Stack align="center" class="w-full max-w-sm" data-demo-qr-status>
<QRCode value="https://hinaui.dev" :status="status" @refresh="refresh" />
<Select
:model-value="status"
:options="options"
aria-label="QR code status"
class="w-48"
@update:model-value="change"
/>
<Text size="sm" tone="muted" class="text-center">
Refresh simulates a request here. The application controls the status.
</Text>
</Stack>
</template>
Custom status
#status replaces the inactive content and receives { status, error, refresh }. Use it for application copy and actions, such as regenerating an expired share link. error represents encoding failures; handle request failures in the application.
Share link expired
Generate a fresh code to continue
<script setup lang="ts">
import { ref } from 'vue'
import { Button, QRCode, Stack, Text } from '@hina-ui/vue'
const expired = ref(true)
</script>
<template>
<Stack align="center" data-demo-qr-custom>
<QRCode
value="https://hinaui.dev"
:status="expired ? 'expired' : 'active'"
:size="224"
@refresh="expired = false"
>
<template #status="{ refresh }">
<Text weight="medium">Share link expired</Text>
<Text size="xs" tone="muted">Generate a fresh code to continue</Text>
<Button size="sm" variant="outline" @click="refresh">Generate again</Button>
</template>
</QRCode>
<Button variant="ghost" size="sm" :disabled="expired" @click="expired = true">
Simulate expiry
</Button>
</Stack>
</template>
Export
Call toBlob() on the component ref to export PNG, or request type: 'image/svg+xml' for SVG. The component creates file contents; the application owns the filename and download action.
Export snapshots the current colors and embeds the logo, so the file needs no page CSS or remote logo URL. Remote logos require CORS permission. Failure to read a logo rejects the Promise rather than silently dropping it. Inactive codes cannot be exported.
<script setup lang="ts">
import { onScopeDispose, ref } from 'vue'
import { Download } from '@lucide/vue'
import { Button, Inline, QRCode, Stack, Text, type QRCodeExpose } from '@hina-ui/vue'
const code = ref<QRCodeExpose>()
const busy = ref(false)
const error = ref(false)
const urls = new Set<string>()
async function save(type: 'image/png' | 'image/svg+xml') {
if (!code.value || busy.value) return
busy.value = true
error.value = false
try {
const blob = await code.value.toBlob({ type, scale: 3 })
const url = URL.createObjectURL(blob)
urls.add(url)
const link = document.createElement('a')
link.href = url
link.download = type === 'image/png' ? 'hina-ui.png' : 'hina-ui.svg'
link.click()
setTimeout(() => {
URL.revokeObjectURL(url)
urls.delete(url)
}, 1000)
} catch {
error.value = true
} finally {
busy.value = false
}
}
onScopeDispose(() => urls.forEach(url => URL.revokeObjectURL(url)))
</script>
<template>
<Stack align="center" data-demo-qr-export>
<QRCode
ref="code"
value="https://hinaui.dev"
logo="/favicon.png"
label="Hina UI documentation"
/>
<Inline gap="sm">
<Button variant="outline" size="sm" :disabled="busy" @click="save('image/png')">
<template #icon><Download /></template>
PNG
</Button>
<Button variant="outline" size="sm" :disabled="busy" @click="save('image/svg+xml')">
<template #icon><Download /></template>
SVG
</Button>
</Inline>
<Text v-if="error" tone="danger" size="sm" role="alert">Export failed. Please try again.</Text>
</Stack>
</template>
import { ref } from 'vue'
import type { QRCodeExpose } from '@hina-ui/vue'
const code = ref<QRCodeExpose>()
const png = await code.value?.toBlob({ scale: 3 })
const svg = await code.value?.toBlob({ type: 'image/svg+xml' })
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Required content to encode |
label | string | Localized “QR code” | Accessible SVG name |
size | number | 192 | Design side including quiet zone, in px; capped at 4096; shrinks in narrow containers |
level | 'L' | 'M' | 'Q' | 'H' | H with logo, otherwise M | Error correction level |
margin | number | 4 | Quiet zone on each side, in modules; integer 0–64 |
color | string | --hn-qr-foreground | Module color |
background | string | --hn-qr-background | Code and logo backing color |
bordered | boolean | true | Outer outline |
logo | string | — | Center image URL, including data URLs |
logoSize | number | 32 | Logo side in px, capped at 25% of the design size |
logoMargin | number | 2 | Logo backing padding in px, capped at 1/32 of the design size |
status | 'active' | 'loading' | 'expired' | 'scanned' | 'active' | Application-controlled state |
class | string | — | Root classes |
Slots
| Slot | Scope | Description |
|---|---|---|
status | QRCodeStatusSlot | Inactive content: { status, error, refresh } |
The resolved QRCodeState also includes empty and error, derived from encoding. The root exposes data-state and adds aria-busy during loading.
Events
| Event | Payload | Description |
|---|---|---|
refresh | — | Requests refresh without changing status |
error | Error | Encoding failed |
logo-error | Event | Center image failed to load |
Expose
| Name | Type | Description |
|---|---|---|
element | HTMLElement | undefined | Root element |
svg | SVGSVGElement | undefined | Active SVG |
toBlob(options?) | Promise<Blob> | Browser export; rejects on failure |
QRCodeExportOptions provides type (default 'image/png') and PNG scale (default 2, range 1–8). PNG uses size × scale, capped at 8192px per side; SVG uses size. QRCodeProps, QRCodeLevel, QRCodeStatus, QRCodeState, QRCodeStatusSlot, QRCodeExportOptions and QRCodeExpose are exported from the package root.