Layr Components

Components / Utility

Scan Code

A QR code drawn in SVG from a link, or from the page it is on, so a visitor on a computer can open it on their phone.

Props
2
0 px
#0E0E0D
#FFFFFF

Scan it with a phone, or type a link in the props.

In Framer, open Assets, add a new code file, paste this over everything in it and save. Scan Code shows up in the Insert menu, and its controls appear in the right panel.
// Scan Code · Layr Components
//
// A QR code drawn in SVG from a link, so someone reading on a computer can
// point their phone at the screen and open the page there. Nothing is fetched
// and no image is needed: change the link and the code redraws itself.
// Paste into a new code file in Framer and give it a square size. With no link it points at the page it is on; on the canvas it shows a stand-in code.
//
// QR matrix code adapted from Project Nayuki's QR Code generator library (MIT License),
// Copyright (c) Project Nayuki. https://www.nayuki.io/page/qr-code-generator-library
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
// - The above copyright notice and this permission notice shall be included in
//   all copies or substantial portions of the Software.
// - The Software is provided "as is", without warranty of any kind, express or
//   implied, including but not limited to the warranties of merchantability,
//   fitness for a particular purpose and noninfringement. In no event shall the
//   authors or copyright holders be liable for any claim, damages or other
//   liability, whether in an action of contract, tort or otherwise, arising from,
//   out of or in connection with the Software or the use or other dealings in the
//   Software.

import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { useSyncExternalStore, type CSSProperties } from "react"

// QR encoder: byte mode, error correction level M, versions 1 to 10 (up to 213 bytes of link).
const EXP = new Uint8Array(512)
const LOG = new Uint8Array(256)
;(() => {
    let x = 1
    for (let i = 0; i < 255; i++) {
        EXP[i] = x
        LOG[x] = i
        x <<= 1
        if (x & 256) x ^= 0x11d
    }
    for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255]
})()
const mul = (a: number, b: number) => (a && b ? EXP[LOG[a] + LOG[b]] : 0)

function remainder(data: number[], ec: number) {
    let gen = [1]
    for (let i = 0; i < ec; i++) {
        const next = new Array(gen.length + 1).fill(0)
        for (let j = 0; j < gen.length; j++) {
            next[j] ^= gen[j]
            next[j + 1] ^= mul(gen[j], EXP[i])
        }
        gen = next
    }
    const res = new Array(ec).fill(0)
    for (const d of data) {
        const f = d ^ res[0]
        res.shift()
        res.push(0)
        for (let j = 0; j < ec; j++) res[j] ^= mul(gen[j + 1], f)
    }
    return res
}

// version: [error correction codewords per block, blocks in group 1, data codewords in each, blocks in group 2]
const BLOCKS: number[][] = [[], [10, 1, 16, 0], [16, 1, 28, 0], [26, 1, 44, 0], [18, 2, 32, 0], [24, 2, 43, 0], [16, 4, 27, 0], [18, 4, 31, 0], [22, 2, 38, 2], [22, 3, 36, 2], [26, 4, 43, 1]]
const ALIGN: number[][] = [[], [], [6, 18], [6, 22], [6, 26], [6, 30], [6, 34], [6, 22, 38], [6, 24, 42], [6, 26, 46], [6, 28, 50]]

function qrMatrix(text: string): boolean[][] | null {
    const bytes = Array.from(new TextEncoder().encode(text))
    let version = 0
    for (let v = 1; v <= 10; v++) {
        const [, g1, d1, g2] = BLOCKS[v]
        const capacity = g1 * d1 + g2 * (d1 + 1)
        if (bytes.length + (v < 10 ? 2 : 3) <= capacity) {
            version = v
            break
        }
    }
    if (!version) return null
    const [ec, g1, d1, g2] = BLOCKS[version]
    const capacity = g1 * d1 + g2 * (d1 + 1)
    // bit stream
    const bits: number[] = []
    const push = (val: number, len: number) => {
        for (let i = len - 1; i >= 0; i--) bits.push((val >>> i) & 1)
    }
    push(4, 4)
    push(bytes.length, version < 10 ? 8 : 16)
    for (const b of bytes) push(b, 8)
    push(0, Math.min(4, capacity * 8 - bits.length))
    while (bits.length % 8) bits.push(0)
    const data: number[] = []
    for (let i = 0; i < bits.length; i += 8) data.push(bits.slice(i, i + 8).reduce((a, b) => (a << 1) | b, 0))
    for (let pad = 0xec; data.length < capacity; pad ^= 0xec ^ 0x11) data.push(pad)
    // blocks and interleaving
    const blocks: number[][] = []
    const ecs: number[][] = []
    let at = 0
    for (let b = 0; b < g1 + g2; b++) {
        const len = d1 + (b < g1 ? 0 : 1)
        const blk = data.slice(at, at + len)
        at += len
        blocks.push(blk)
        ecs.push(remainder(blk, ec))
    }
    const all: number[] = []
    for (let i = 0; i <= d1; i++) for (const blk of blocks) if (i < blk.length) all.push(blk[i])
    for (let i = 0; i < ec; i++) for (const e of ecs) all.push(e[i])
    // matrix
    const size = 17 + 4 * version
    const m: boolean[][] = Array.from({ length: size }, () => new Array(size).fill(false))
    const fn: boolean[][] = Array.from({ length: size }, () => new Array(size).fill(false))
    const set = (x: number, y: number, dark: boolean) => {
        if (x < 0 || y < 0 || x >= size || y >= size) return
        m[y][x] = dark
        fn[y][x] = true
    }
    for (let i = 0; i < size; i++) {
        set(6, i, i % 2 === 0)
        set(i, 6, i % 2 === 0)
    }
    const finder = (cx: number, cy: number) => {
        for (let dy = -4; dy <= 4; dy++)
            for (let dx = -4; dx <= 4; dx++) {
                const d = Math.max(Math.abs(dx), Math.abs(dy))
                set(cx + dx, cy + dy, d !== 2 && d !== 4)
            }
    }
    finder(3, 3)
    finder(size - 4, 3)
    finder(3, size - 4)
    const al = ALIGN[version]
    for (let i = 0; i < al.length; i++)
        for (let j = 0; j < al.length; j++) {
            if ((i === 0 && j === 0) || (i === 0 && j === al.length - 1) || (i === al.length - 1 && j === 0)) continue
            for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) set(al[i] + dx, al[j] + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1)
        }
    const format = (mask: number) => {
        const d = mask // level M has format bits 00
        let rem = d
        for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537)
        const f = ((d << 10) | rem) ^ 0x5412
        const bit = (i: number) => ((f >>> i) & 1) === 1
        for (let i = 0; i <= 5; i++) set(8, i, bit(i))
        set(8, 7, bit(6))
        set(8, 8, bit(7))
        set(7, 8, bit(8))
        for (let i = 9; i < 15; i++) set(14 - i, 8, bit(i))
        for (let i = 0; i < 8; i++) set(size - 1 - i, 8, bit(i))
        for (let i = 8; i < 15; i++) set(8, size - 15 + i, bit(i))
        set(8, size - 8, true)
    }
    format(0)
    if (version >= 7) {
        let rem = version
        for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25)
        const v = (version << 12) | rem
        for (let i = 0; i < 18; i++) {
            const dark = ((v >>> i) & 1) === 1
            const a = size - 11 + (i % 3)
            const b = Math.floor(i / 3)
            set(a, b, dark)
            set(b, a, dark)
        }
    }
    let k = 0
    for (let right = size - 1; right >= 1; right -= 2) {
        if (right === 6) right = 5
        for (let vert = 0; vert < size; vert++)
            for (let j = 0; j < 2; j++) {
                const x = right - j
                const up = ((right + 1) & 2) === 0
                const y = up ? size - 1 - vert : vert
                if (!fn[y][x] && k < all.length * 8) {
                    m[y][x] = ((all[k >>> 3] >>> (7 - (k & 7))) & 1) === 1
                    k++
                }
            }
    }
    const masks = [
        (x: number, y: number) => (x + y) % 2 === 0,
        (_x: number, y: number) => y % 2 === 0,
        (x: number) => x % 3 === 0,
        (x: number, y: number) => (x + y) % 3 === 0,
        (x: number, y: number) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0,
        (x: number, y: number) => ((x * y) % 2) + ((x * y) % 3) === 0,
        (x: number, y: number) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0,
        (x: number, y: number) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0,
    ]
    const apply = (mask: number) => {
        for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) if (!fn[y][x] && masks[mask](x, y)) m[y][x] = !m[y][x]
    }
    const penalty = () => {
        let p = 0
        let dark = 0
        for (let y = 0; y < size; y++) {
            let runR = 1
            let runC = 1
            for (let x = 0; x < size; x++) {
                if (m[y][x]) dark++
                if (x > 0) {
                    runR = m[y][x] === m[y][x - 1] ? runR + 1 : 1
                    if (runR === 5) p += 3
                    else if (runR > 5) p++
                    runC = m[x][y] === m[x - 1][y] ? runC + 1 : 1
                    if (runC === 5) p += 3
                    else if (runC > 5) p++
                }
                if (x > 0 && y > 0 && m[y][x] === m[y][x - 1] && m[y][x] === m[y - 1][x] && m[y][x] === m[y - 1][x - 1]) p += 3
            }
        }
        return p + Math.floor(Math.abs((dark * 20) / (size * size) - 10)) * 10
    }
    let best = 0
    let low = Infinity
    for (let mask = 0; mask < 8; mask++) {
        apply(mask)
        format(mask)
        const p = penalty()
        if (p < low) {
            low = p
            best = mask
        }
        apply(mask)
    }
    apply(best)
    format(best)
    return m
}


// The address of the page the code sits on, without query or hash. It is only known in the
// browser, so the server (and the first paint) has none; it is read again after a page change.
const subscribe = (change: () => void) => {
    window.addEventListener("popstate", change)
    window.addEventListener("hashchange", change)
    const poll = window.setInterval(change, 1000)
    return () => {
        window.removeEventListener("popstate", change)
        window.removeEventListener("hashchange", change)
        window.clearInterval(poll)
    }
}
const here = () => window.location.origin + window.location.pathname
const nowhere = () => ""

// Empty: this page. Starting with /: that page of this site. Anything else is used as it is.
function target(link: string, page: string) {
    const l = String(link || "").trim()
    if (!l) return page
    if (l.startsWith("/")) return page ? new URL(page).origin + l : ""
    return l
}

function pathOf(m: boolean[][], q: number) {
    const n = m.length
    let d = ""
    for (let y = 0; y < n; y++) {
        let x = 0
        while (x < n) {
            if (!m[y][x]) {
                x++
                continue
            }
            let w = 1
            while (x + w < n && m[y][x + w]) w++
            d += `M${x + q} ${y + q}h${w}v1h-${w}z`
            x += w
        }
    }
    return d
}

interface ScanCodeProps {
    link: string
    label: string
    quiet: number
    corner: number
    codeColour: string
    groundColour: string
    style?: CSSProperties
}

/**
 * @framerIntrinsicWidth 148
 * @framerIntrinsicHeight 148
 *
 * @framerSupportedLayoutWidth fixed
 * @framerSupportedLayoutHeight fixed
 */
export default function ScanCode(props: ScanCodeProps) {
    const { link = "", label = "Scan to open this page on your phone", quiet = 2, corner = 0, codeColour = "#0C0E11", groundColour = "#FFFFFF", style } = props
    const isStatic = useIsStaticRenderer()
    const page = useSyncExternalStore(subscribe, here, nowhere)
    // On the canvas the page's address is Framer's own, so a stand-in is drawn instead.
    const text = target(link, isStatic ? "https://example.com" : page)
    const q = Math.max(0, Math.round(quiet))
    const box: CSSProperties = { ...style, display: "block", width: "100%", height: "100%", borderRadius: corner, background: groundColour }
    if (!text) return <svg role="img" aria-label={label} viewBox="0 0 25 25" style={box} />
    const m = qrMatrix(text)
    if (!m) return <div style={{ ...box, display: "grid", placeItems: "center", fontSize: 12, textAlign: "center", color: codeColour }}>This link is too long for a scan code.</div>
    const full = m.length + q * 2
    return (
        <svg role="img" aria-label={label} viewBox={`0 0 ${full} ${full}`} shapeRendering="crispEdges" style={box}>
            <path d={pathOf(m, q)} fill={codeColour} />
        </svg>
    )
}

addPropertyControls(ScanCode, {
    link: { type: ControlType.String, title: "Link", defaultValue: "", placeholder: "This page", description: "The address the code opens. Leave empty for the page it is on, or start with / for another page of this site." },
    label: { type: ControlType.String, title: "Alt text", defaultValue: "Scan to open this page on your phone", description: "What the code is for, for screen readers." },
    quiet: { type: ControlType.Number, title: "Margin", defaultValue: 2, min: 0, max: 6, step: 1, description: "Empty squares round the code. Phones read it more easily with two or more." },
    corner: { type: ControlType.Number, title: "Corner", defaultValue: 0, min: 0, max: 40, step: 1, unit: "px" },
    codeColour: { type: ControlType.Color, title: "Code colour", defaultValue: "#0C0E11" },
    groundColour: { type: ControlType.Color, title: "Ground colour", defaultValue: "#FFFFFF" },
})

In this preview

No pictures needed.