Company Card
Customize
Obsidian
Grain0.70
Gradient
From
To
Angle125°
Glow0.75
Opacity0.85
Noise0.95
Thickness3.0px
Foil
Logo shine1.00
Relief1.30
Holo glitter
Overlay strength1.65
Holo size1.00
Colour1.00
Glitter amount1.25
Flake size3.5px
Contours
Pattern strength0.85
Line density1.05
Orbit
Accent
Light colour
Tilt8°
Radius18px
Width420px
Usage
import { CompanyCard } from "@/components/uibrik/company-card/company-card";
<CompanyCard />Installation
npx shadcn@latest add https://uibrik.com/r/company-card.jsonInstall the packages
npm install threeAdd the cn helper
Imports
cnfrom@/lib/utils, whichshadcn initcreates. Add it by hand without shadcn.Copy the source into your project
components/uibrik/company-card/company-card.tsx"use client"; import { useEffect, useMemo, useRef, useState, type CSSProperties, type HTMLAttributes, type PointerEvent } from "react"; import * as THREE from "three"; import { cn } from "@/lib/utils"; import { edgeFragment, faceFragment, shadowFragment, shadowVertex, vertex } from "./shader"; export type CardMaterial = "paper" | "metal" | "glass" | "obsidian"; export type LogoFinish = "foil" | "holo" | "ink" | "blind"; export type CardOverlay = "none" | "holographic" | "glitter" | "holoGlitter" | "lenticular" | "pearl"; export type CardMotif = "contours" | "guilloche" | "none"; export type CardFill = "stock" | "solid" | "gradient" | "mesh"; export type CompanyCardProps = Omit<HTMLAttributes<HTMLElement>, "children"> & { company: string; /** Logo image URL, ideally an SVG on a transparent background. Same-origin or CORS-enabled. Without one, a monogram is stamped instead. */ logo?: string; tagline?: string; name?: string; role?: string; email?: string; website?: string; phone?: string; location?: string; material?: CardMaterial; /** What's printed on the stock: its own colour, a solid, a gradient ending in a glow, or a mesh of three colours. */ fill?: CardFill; /** Colour for the solid fill. */ fillColor?: string; /** Gradient and mesh start colour. */ fillFrom?: string; /** Gradient and mesh end colour. */ fillTo?: string; /** Mesh highlight colour. */ fillHighlight?: string; /** Gradient direction in degrees, as in CSS (135 runs top left to bottom right). */ fillAngle?: number; /** Bloom of light where the gradient ends. */ fillGlow?: number; /** Fine grain over the fill. */ fillNoise?: number; /** How opaque the fill is over the stock's own colour, 0 to 1. */ fillOpacity?: number; /** How the logo is printed: hot foil, a holographic kinegram, flat ink in its own colours, or blind relief. */ logoFinish?: LogoFinish; /** A finish over the whole face: holographic film, prismatic glitter, both together, a lenticular sheet that flips between patterns, or pearl. */ overlay?: CardOverlay; overlayStrength?: number; /** The pattern printed from the company name: topographic contours or a guilloche rosette. */ motif?: CardMotif; /** The card dips under a press and a ring of light rolls out from it. */ ripple?: boolean; /** Motif line density, 1 = default. */ lineDensity?: number; /** Guilloche petal count; 0 picks one from the company name. */ petals?: number; /** Lenticular lens width, px. */ lensPitch?: number; /** Size of the holo glow, or of the hover spot for holo glitter. */ holoSpread?: number; /** Colour in the holographic and pearl overlays, 1 = default. */ saturation?: number; /** Hide a large, faint copy of the logo in the holographic film. */ hiddenLogo?: boolean; /** How many glitter flakes, 1 = default. */ glitterDensity?: number; /** Glitter flake spacing, px. */ flakeSize?: number; /** Foil, edge paint and glass glow colour. Taken from the logo when left out. */ accent?: string; lightColor?: string; /** Card width in px; it never grows past its container. */ width?: number; radius?: number; thickness?: number; /** Largest tilt toward the pointer, degrees. */ tilt?: number; foil?: number; relief?: number; grain?: number; pattern?: number; }; // Room around the card for the edge, the tilt and the shadow. const PAD = 56; // Viewer distance in px. The canvas camera and the CSS perspective share it, so // the invisible HTML layer stays glued to the rendered face for selection. const DISTANCE = 1400; const LIGHT_Z = 340; const FALLBACK_ACCENT = "#c9a86a"; const MATERIALS: Record< CardMaterial, { id: number; base: string; ink: string; ink2: string; alpha: number; edge?: string; edgeMetal: number; edgeRough: number; edgeGlow: number } > = { // Edge painted in the accent, the way letterpress shops finish thick stock. paper: { id: 0, base: "#f1ede4", ink: "#1c1a17", ink2: "#7a756c", alpha: 1, edgeMetal: 0, edgeRough: 0.8, edgeGlow: 0 }, metal: { id: 1, base: "#44474d", ink: "#e4e6e9", ink2: "#a3a7ad", alpha: 1, edge: "#cdd0d5", edgeMetal: 1, edgeRough: 0.22, edgeGlow: 0 }, glass: { id: 2, base: "#0d1013", ink: "#f5f7fa", ink2: "#a7b0ba", alpha: 0.9, edgeMetal: 0, edgeRough: 0.1, edgeGlow: 0.35 }, obsidian: { id: 3, base: "#0c0c0d", ink: "#b9b9be", ink2: "#6c6c72", alpha: 1, edge: "#141416", edgeMetal: 0, edgeRough: 0.3, edgeGlow: 0 }, }; type Gl = { renderer: THREE.WebGLRenderer; scene: THREE.Scene; camera: THREE.PerspectiveCamera; card: THREE.Mesh; face: THREE.RawShaderMaterial; edge: THREE.RawShaderMaterial; shadow: THREE.RawShaderMaterial; shadowMesh: THREE.Mesh; }; const KEYS = ["rx", "ry", "lx", "ly", "lift", "hover"] as const; type Key = (typeof KEYS)[number]; const EPS: Record<Key, number> = { rx: 1e-4, ry: 1e-4, lx: 0.05, ly: 0.05, lift: 1e-3, hover: 1e-3 }; /** Two stable numbers from the company name, so its contour lines never change. */ function seedOf(text: string): [number, number] { let h = 2166136261; for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 16777619); return [((h >>> 0) % 997) / 9.97, ((Math.imul(h, 2654435761) >>> 0) % 991) / 9.91]; } /** Can this image be read back? A tainted one would break the texture upload. */ function readable(img: HTMLImageElement) { try { const c = document.createElement("canvas"); c.width = c.height = 1; const ctx = c.getContext("2d"); ctx?.drawImage(img, 0, 0, 1, 1); ctx?.getImageData(0, 0, 1, 1); return true; } catch { return false; } } /** The logo's most saturated colour, or null for a monochrome mark. */ function accentOf(img: HTMLImageElement): string | null { const size = 32; const c = document.createElement("canvas"); c.width = c.height = size; const ctx = c.getContext("2d", { willReadFrequently: true }); if (!ctx) return null; ctx.drawImage(img, 0, 0, size, size); let data: Uint8ClampedArray; try { data = ctx.getImageData(0, 0, size, size).data; } catch { return null; } let r = 0, g = 0, b = 0, total = 0; for (let i = 0; i < data.length; i += 4) { const a = data[i + 3] / 255; const max = Math.max(data[i], data[i + 1], data[i + 2]); const min = Math.min(data[i], data[i + 1], data[i + 2]); if (a < 0.5 || max < 40) continue; const w = ((max - min) / max) ** 2 * a; r += data[i] * w; g += data[i + 1] * w; b += data[i + 2] * w; total += w; } if (total < 4) return null; const hex = (v: number) => Math.round(v / total).toString(16).padStart(2, "0"); return `#${hex(r)}${hex(g)}${hex(b)}`; } /** Layout box of `el` relative to `root`, ignoring transforms. */ function boxIn(el: HTMLElement, root: HTMLElement) { let x = 0, y = 0; let node: HTMLElement | null = el; while (node && node !== root) { x += node.offsetLeft; y += node.offsetTop; node = node.offsetParent as HTMLElement | null; } return { x, y, w: el.offsetWidth, h: el.offsetHeight }; } function buildSlab(w: number, h: number, radius: number, thickness: number) { const b = Math.min(1, thickness * 0.3); const iw = w / 2 - b, ih = h / 2 - b; const r = Math.min(Math.max(radius - b, 0.5), iw, ih); const shape = new THREE.Shape(); shape.moveTo(-iw + r, -ih); shape.lineTo(iw - r, -ih); shape.absarc(iw - r, -ih + r, r, -Math.PI / 2, 0, false); shape.lineTo(iw, ih - r); shape.absarc(iw - r, ih - r, r, 0, Math.PI / 2, false); shape.lineTo(-iw + r, ih); shape.absarc(-iw + r, ih - r, r, Math.PI / 2, Math.PI, false); shape.lineTo(-iw, -ih + r); shape.absarc(-iw + r, -ih + r, r, Math.PI, Math.PI * 1.5, false); const geometry = new THREE.ExtrudeGeometry(shape, { depth: Math.max(thickness - 2 * b, 0.1), bevelEnabled: true, bevelThickness: b, bevelSize: b, bevelSegments: 3, curveSegments: 24, }); // Face at z = 0, the slab behind it, like the HTML layer. geometry.computeBoundingBox(); geometry.translate(0, 0, -geometry.boundingBox!.max.z); return geometry; } export function CompanyCard({ company, logo, tagline, name, role, email, website, phone, location, material = "obsidian", fill = "gradient", fillColor = "#1d3bff", fillFrom = "#01000a", fillTo = "#5a2ee8", fillHighlight = "#c8b8ff", fillAngle = 125, fillGlow = 0.75, fillNoise = 0.95, fillOpacity = 0.85, logoFinish = "foil", accent, lightColor = "#ffffff", width = 420, radius = 18, thickness = 3, tilt = 8, foil = 1, relief = 1.3, grain = 0.7, pattern = 0.85, overlay = "holoGlitter", overlayStrength = 1.65, motif = "contours", ripple = true, lineDensity = 1.05, petals = 0, lensPitch = 3, holoSpread = 1, saturation = 1, hiddenLogo = true, glitterDensity = 1.25, flakeSize = 3.5, className, style, onPointerMove, onPointerDown, onPointerUp, onPointerLeave, ...rest }: CompanyCardProps) { const rootRef = useRef<HTMLElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null); const layerRef = useRef<HTMLDivElement>(null); const glRef = useRef<Gl | null>(null); // Both keyed by the logo URL, so a new logo starts clean. const [logoAccent, setLogoAccent] = useState<{ src: string; color: string | null }>(); const [failedLogo, setFailedLogo] = useState<string>(); const tone = accent ?? (logoAccent?.src === logo ? logoAccent?.color : null) ?? FALLBACK_ACCENT; const mat = MATERIALS[material] ?? MATERIALS.obsidian; const seed = useMemo(() => seedOf(company), [company]); const showLogo = !!logo && failedLogo !== logo; // Springs live in a ref so the loop never re-renders React. const sim = useRef({ x: { rx: 0, ry: 0, lx: 0, ly: 0, lift: 0, hover: 0 } as Record<Key, number>, v: { rx: 0, ry: 0, lx: 0, ly: 0, lift: 0, hover: 0 } as Record<Key, number>, t: { rx: 0, ry: 0, lx: 0, ly: 0, lift: 0, hover: 0 } as Record<Key, number>, lightK: 22, hover: false, reduced: false, visible: false, introduced: false, pressed: false, ripple: true, rippleT: -1, raf: 0, last: 0, tilt: 8, }); useEffect(() => { sim.current.tilt = tilt; }, [tilt]); // Imperative hooks the setup effect fills in. const api = useRef({ layout: () => {}, paint: () => {}, render: () => {}, kick: () => {} }); // Latest geometry props, read by layout(). const shapeRef = useRef({ radius, thickness }); useEffect(() => { const root = rootRef.current; const canvas = canvasRef.current; const layer = layerRef.current; if (!root || !canvas || !layer) return; let renderer: THREE.WebGLRenderer; try { renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true, premultipliedAlpha: true }); } catch { root.dataset.fallback = ""; return; } renderer.setClearColor(0x000000, 0); const shared = { uMaterial: { value: 0 }, uLight: { value: new THREE.Vector3() }, uLightColor: { value: new THREE.Color() }, uAccent: { value: new THREE.Color() }, uAlpha: { value: 1 }, uHover: { value: 0 }, }; // Type and logo are painted into two canvases: masks (r logo, g primary // text, b secondary text, mipmapped so the shader can blur them into // relief) and the logo in its own colours for the ink finish. const masks = document.createElement("canvas"); const colors = document.createElement("canvas"); const scratch = document.createElement("canvas"); // One rect per line of type, coloured with that line's centre and width, // so the shader can pick one ink for the whole line. const anchors = document.createElement("canvas"); const anchorsTex = new THREE.CanvasTexture(anchors); anchorsTex.minFilter = anchorsTex.magFilter = THREE.NearestFilter; anchorsTex.generateMipmaps = false; const masksTex = new THREE.CanvasTexture(masks); masksTex.minFilter = THREE.LinearMipmapLinearFilter; masksTex.generateMipmaps = true; const colorsTex = new THREE.CanvasTexture(colors); colorsTex.colorSpace = THREE.SRGBColorSpace; colorsTex.minFilter = THREE.LinearFilter; colorsTex.generateMipmaps = false; const raw = (fragmentShader: string, uniforms: Record<string, THREE.IUniform>, vertexShader = vertex) => new THREE.RawShaderMaterial({ glslVersion: THREE.GLSL3, vertexShader, fragmentShader, uniforms, transparent: true, premultipliedAlpha: true, }); const face = raw(faceFragment, { ...shared, uCard: { value: new THREE.Vector2(1, 1) }, uMasks: { value: masksTex }, uLogoColor: { value: colorsTex }, uAnchors: { value: anchorsTex }, uLod: { value: 1 }, uLogoFinish: { value: 0 }, uLogoBox: { value: new THREE.Vector4() }, uMotif: { value: 0 }, uOverlay: { value: 0 }, uOverlayStrength: { value: 1 }, uRipple: { value: new THREE.Vector4() }, uFoil: { value: 1 }, uRelief: { value: 1 }, uGrain: { value: 1 }, uPattern: { value: 1 }, uSeed: { value: new THREE.Vector2() }, uLines: { value: 1 }, uFill: { value: 0 }, uFillA: { value: new THREE.Color() }, uFillB: { value: new THREE.Color() }, uFillC: { value: new THREE.Color() }, uFillAngle: { value: 0 }, uFillGlow: { value: 0 }, uFillNoise: { value: 0 }, uFillOpacity: { value: 1 }, uPetals: { value: 0 }, uLensPitch: { value: 3 }, uSpread: { value: 1 }, uSaturation: { value: 1 }, uHiddenLogo: { value: 1 }, uDensity: { value: 1 }, uFlakeSize: { value: 3 }, uDepth: { value: 3 }, uBase: { value: new THREE.Color() }, uInk: { value: new THREE.Color() }, uInk2: { value: new THREE.Color() }, }); const edge = raw(edgeFragment, { ...shared, uEdge: { value: new THREE.Color() }, uEdgeMetal: { value: 0 }, uEdgeRough: { value: 0.5 }, uEdgeGlow: { value: 0 }, uEdgeHolo: { value: 0 }, uEdgeHoverOnly: { value: 0 }, }); const shadow = raw( shadowFragment, { uHalf: { value: new THREE.Vector2() }, uRadius: { value: 16 }, uOffset: { value: new THREE.Vector2() }, uBlur: { value: 20 }, uOpacity: { value: 0.25 }, }, shadowVertex, ); shadow.depthWrite = false; const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(30, 1, 1, DISTANCE * 4); camera.position.set(0, 0, DISTANCE); const shadowMesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), shadow); shadowMesh.position.z = -36; shadowMesh.renderOrder = 0; const card = new THREE.Mesh(new THREE.BufferGeometry(), [face, edge]); card.renderOrder = 1; scene.add(shadowMesh, card); glRef.current = { renderer, scene, camera, card, face, edge, shadow, shadowMesh }; const s = sim.current; const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); s.reduced = mq.matches; let size = { w: 0, h: 0, radius: 0, thickness: 0 }; const rest = () => ({ lx: size.w * 0.22, ly: size.h * 0.62 }); const render = () => { if (s.visible) renderer.render(scene, camera); }; const apply = () => { const lift = s.x.lift * 10; card.rotation.set(s.x.rx, s.x.ry, 0); card.position.z = lift; layer.style.transform = `translateZ(${lift}px) rotateX(${-s.x.rx}rad) rotateY(${s.x.ry}rad)`; shared.uLight.value.set(s.x.lx, s.x.ly, LIGHT_Z); shared.uHover.value = Math.min(Math.max(s.x.hover, 0), 1); const su = shadow.uniforms; su.uOffset.value.set(-s.x.lx * 0.035 + s.x.ry * 30, -s.x.ly * 0.035 - 8 - lift * 0.6 - s.x.rx * 30); su.uBlur.value = 16 + lift * 1.4; su.uOpacity.value = 0.3 - lift * 0.008; render(); }; const tick = (now: number) => { const dt = Math.min((now - s.last) / 1000, 1 / 30); s.last = now; let moving = false; for (const k of KEYS) { const stiff = k === "lx" || k === "ly" ? s.lightK : k === "lift" ? 220 : k === "hover" ? 90 : 170; const zeta = k === "lx" || k === "ly" || k === "hover" ? 1 : k === "lift" ? 0.9 : 0.8; const c = 2 * Math.sqrt(stiff) * zeta; for (let i = 0; i < 4; i++) { const h = dt / 4; s.v[k] += (-stiff * (s.x[k] - s.t[k]) - c * s.v[k]) * h; s.x[k] += s.v[k] * h; } if (Math.abs(s.x[k] - s.t[k]) > EPS[k] || Math.abs(s.v[k]) > EPS[k]) moving = true; else { s.x[k] = s.t[k]; s.v[k] = 0; } } const rip = face.uniforms.uRipple.value; if (s.rippleT >= 0) { s.rippleT += dt; if (s.rippleT > 1.6) s.rippleT = -1; else moving = true; } rip.z = Math.max(s.rippleT, 0); rip.w = s.rippleT >= 0 ? 1 : 0; apply(); s.raf = moving ? requestAnimationFrame(tick) : 0; }; const kick = () => { if (s.raf) return; s.last = performance.now(); s.raf = requestAnimationFrame(tick); }; const paint = () => { const w = layer.offsetWidth, h = layer.offsetHeight; if (!w || !h) return; const scale = Math.min(Math.max(renderer.getPixelRatio() * 1.5, 1), 4096 / w); const cw = Math.round(w * scale), ch = Math.round(h * scale); // three.js allocates texture storage once; a new size needs a fresh texture. if (masks.width !== cw || masks.height !== ch) { masksTex.dispose(); colorsTex.dispose(); masks.width = colors.width = cw; masks.height = colors.height = ch; } if (anchors.width !== w || anchors.height !== h) { anchorsTex.dispose(); anchors.width = w; anchors.height = h; } const an = anchors.getContext("2d"); if (!an) return; an.clearRect(0, 0, w, h); const m = masks.getContext("2d"); const c = colors.getContext("2d"); const t = scratch.getContext("2d"); if (!m || !c || !t) return; m.setTransform(1, 0, 0, 1, 0, 0); m.globalCompositeOperation = "source-over"; m.fillStyle = "#000"; m.fillRect(0, 0, cw, ch); m.setTransform(scale, 0, 0, scale, 0, 0); m.globalCompositeOperation = "lighter"; c.setTransform(1, 0, 0, 1, 0, 0); c.clearRect(0, 0, cw, ch); c.setTransform(scale, 0, 0, scale, 0, 0); const font = (el: HTMLElement, ctx: CanvasRenderingContext2D) => { const cs = getComputedStyle(el); ctx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`; if ("letterSpacing" in ctx) ctx.letterSpacing = cs.letterSpacing === "normal" ? "0px" : cs.letterSpacing; return cs; }; // Draws `draw` into the scratch canvas, recolours it to `channel`, and // adds it to the masks, so overlapping marks never bleed across channels. const stamp = (box: { x: number; y: number; w: number; h: number }, channel: string, draw: (ctx: CanvasRenderingContext2D, w: number, h: number) => void) => { const sw = Math.max(1, Math.ceil(box.w * scale)), sh = Math.max(1, Math.ceil(box.h * scale)); scratch.width = sw; scratch.height = sh; t.setTransform(1, 0, 0, 1, 0, 0); t.globalCompositeOperation = "source-over"; t.clearRect(0, 0, sw, sh); draw(t, sw, sh); t.globalCompositeOperation = "source-in"; t.fillStyle = channel; t.fillRect(0, 0, sw, sh); m.drawImage(scratch, box.x, box.y, box.w, box.h); }; for (const el of layer.querySelectorAll<HTMLElement>("[data-ink]")) { const box = boxIn(el, layer); const kind = el.dataset.ink; if (kind === "logo") { // Centre and half size, in card space (y up), for the holo effects. face.uniforms.uLogoBox.value.set(box.x + box.w / 2 - w / 2, h / 2 - box.y - box.h / 2, box.w / 2, box.h / 2); if (el instanceof HTMLImageElement) { if (!el.complete || !el.naturalWidth || !readable(el)) continue; stamp(box, "#f00", (ctx, sw, sh) => ctx.drawImage(el, 0, 0, sw, sh)); c.drawImage(el, box.x, box.y, box.w, box.h); } else { // Monogram: a rounded tile with the initial cut out of it. stamp(box, "#f00", (ctx, sw, sh) => { ctx.beginPath(); ctx.roundRect(0, 0, sw, sh, sw * 0.24); ctx.fillStyle = "#fff"; ctx.fill(); ctx.globalCompositeOperation = "destination-out"; ctx.scale(scale, scale); font(el, ctx); ctx.textAlign = "center"; const text = el.textContent ?? ""; const tm = ctx.measureText(text); ctx.fillText(text, box.w / 2, box.h / 2 + (tm.actualBoundingBoxAscent - tm.actualBoundingBoxDescent) / 2); ctx.setTransform(1, 0, 0, 1, 0, 0); }); } continue; } const cs = font(el, m); let text = el.textContent ?? ""; if (m.measureText(text).width > box.w + 0.5) { while (text.length > 1 && m.measureText(text + "…").width > box.w) text = text.slice(0, -1); text = text.trimEnd() + "…"; } const align = cs.textAlign === "right" || cs.textAlign === "end" ? "right" : cs.textAlign === "center" ? "center" : "left"; m.textAlign = align; const x = align === "right" ? box.x + box.w : align === "center" ? box.x + box.w / 2 : box.x; const tm = m.measureText(text); m.fillStyle = kind === "primary" ? "#0f0" : "#00f"; m.fillText(text, x, box.y + box.h / 2 + (tm.fontBoundingBoxAscent - tm.fontBoundingBoxDescent) / 2); const left = align === "right" ? x - tm.width : align === "center" ? x - tm.width / 2 : x; const cx = left + tm.width / 2, cy = box.y + box.h / 2; an.fillStyle = `rgb(${Math.round((cx / w) * 255)}, ${Math.round((1 - cy / h) * 255)}, ${Math.max(1, Math.round(Math.min(tm.width / w, 1) * 255))})`; an.fillRect(Math.floor(left - 3), Math.floor(box.y - 2), Math.ceil(tm.width + 6), Math.ceil(box.h + 4)); } masksTex.needsUpdate = true; colorsTex.needsUpdate = true; anchorsTex.needsUpdate = true; // At this mip level one texel covers about one CSS pixel. face.uniforms.uLod.value = Math.log2(scale); render(); }; const layout = () => { const w = root.offsetWidth, h = root.offsetHeight; if (!w || !h) return; const { radius, thickness } = shapeRef.current; renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); const cw = w + PAD * 2, ch = h + PAD * 2; renderer.setSize(cw, ch, false); camera.fov = (2 * Math.atan(ch / 2 / DISTANCE) * 180) / Math.PI; camera.aspect = cw / ch; camera.updateProjectionMatrix(); if (w !== size.w || h !== size.h || radius !== size.radius || thickness !== size.thickness) { card.geometry.dispose(); card.geometry = buildSlab(w, h, radius, thickness); face.uniforms.uDepth.value = thickness; } const resized = w !== size.w || h !== size.h; size = { w, h, radius, thickness }; face.uniforms.uCard.value.set(w, h); shadowMesh.scale.set(w + PAD * 2, h + PAD * 2, 1); shadow.uniforms.uHalf.value.set(w / 2, h / 2); shadow.uniforms.uRadius.value = radius; if (!s.hover) { const r = rest(); s.t.lx = r.lx; s.t.ly = r.ly; // Before the intro runs, park the light off the left edge so it sweeps in. if (!s.introduced) { s.x.lx = s.reduced ? r.lx : -w * 1.1; s.x.ly = s.reduced ? r.ly : h * 0.9; } else if (resized) { s.x.lx = r.lx; s.x.ly = r.ly; } } if (resized) paint(); apply(); }; api.current = { layout, paint, render, kick }; const ro = new ResizeObserver(layout); ro.observe(root); layout(); document.fonts?.ready.then(paint); // Only draw while on screen; the first time it shows, the light sweeps across once. const io = new IntersectionObserver(([entry]) => { s.visible = entry.isIntersecting; if (!s.visible) return; if (!s.introduced) { s.introduced = true; s.lightK = 22; } apply(); kick(); }); io.observe(root); const onMotion = () => (s.reduced = mq.matches); mq.addEventListener("change", onMotion); const onLost = (e: Event) => { e.preventDefault(); delete root.dataset.gl; root.dataset.fallback = ""; }; canvas.addEventListener("webglcontextlost", onLost); root.dataset.gl = ""; return () => { cancelAnimationFrame(s.raf); s.raf = 0; ro.disconnect(); io.disconnect(); mq.removeEventListener("change", onMotion); canvas.removeEventListener("webglcontextlost", onLost); card.geometry.dispose(); shadowMesh.geometry.dispose(); face.dispose(); edge.dispose(); shadow.dispose(); masksTex.dispose(); colorsTex.dispose(); anchorsTex.dispose(); renderer.dispose(); glRef.current = null; layer.style.transform = ""; delete root.dataset.gl; }; }, []); // Look: props feed straight into uniforms. useEffect(() => { const gl = glRef.current; if (!gl) return; const f = gl.face.uniforms; const e = gl.edge.uniforms; f.uMaterial.value = mat.id; f.uAlpha.value = mat.alpha; f.uAccent.value.set(tone); f.uLightColor.value.set(lightColor); f.uBase.value.set(mat.base); f.uInk.value.set(mat.ink); f.uInk2.value.set(mat.ink2); f.uLogoFinish.value = { foil: 0, ink: 1, blind: 2, holo: 3 }[logoFinish] ?? 0; f.uOverlay.value = { none: 0, holographic: 1, glitter: 2, lenticular: 3, pearl: 4, holoGlitter: 5 }[overlay] ?? 0; f.uOverlayStrength.value = overlayStrength; f.uMotif.value = { contours: 0, guilloche: 1, none: 2 }[motif] ?? 0; f.uLines.value = lineDensity; f.uFill.value = { stock: 0, solid: 1, gradient: 2, mesh: 3 }[fill] ?? 0; f.uFillA.value.set(fill === "solid" ? fillColor : fillFrom); f.uFillB.value.set(fillTo); f.uFillC.value.set(fillHighlight); f.uFillAngle.value = (fillAngle * Math.PI) / 180; f.uFillGlow.value = fillGlow; f.uFillNoise.value = fillNoise; f.uFillOpacity.value = Math.min(Math.max(fillOpacity, 0), 1); f.uPetals.value = Math.round(petals); f.uLensPitch.value = Math.max(lensPitch, 1); f.uSpread.value = holoSpread; f.uSaturation.value = saturation; f.uHiddenLogo.value = hiddenLogo ? 1 : 0; f.uDensity.value = glitterDensity; f.uFlakeSize.value = Math.max(flakeSize, 1); f.uFoil.value = foil; f.uRelief.value = relief; f.uGrain.value = grain; f.uPattern.value = pattern; f.uSeed.value.set(seed[0], seed[1]); if (mat.edge) e.uEdge.value.set(mat.edge); else if (material === "glass") e.uEdge.value.set(tone).lerp(new THREE.Color(1, 1, 1), 0.55); else e.uEdge.value.set(tone); // A coloured soft-touch card shows its colour on the cut edge. if (fill !== "stock" && material === "obsidian") e.uEdge.value.set(fill === "solid" ? fillColor : fillFrom).multiplyScalar(0.6); e.uEdgeMetal.value = mat.edgeMetal; e.uEdgeRough.value = mat.edgeRough; e.uEdgeGlow.value = mat.edgeGlow; e.uEdgeHolo.value = overlay === "holographic" || overlay === "holoGlitter" || overlay === "pearl" ? overlayStrength : 0; e.uEdgeHoverOnly.value = overlay === "holoGlitter" ? 1 : 0; api.current.render(); }, [mat, material, tone, lightColor, logoFinish, foil, relief, grain, pattern, seed, overlay, overlayStrength, motif, lineDensity, petals, lensPitch, holoSpread, saturation, hiddenLogo, glitterDensity, flakeSize, fill, fillColor, fillFrom, fillTo, fillHighlight, fillAngle, fillGlow, fillNoise, fillOpacity]); useEffect(() => { sim.current.ripple = ripple; }, [ripple]); // Shape. useEffect(() => { shapeRef.current = { radius, thickness }; api.current.layout(); }, [radius, thickness, width]); // Content: repaint once the new text has laid out. useEffect(() => { const id = requestAnimationFrame(() => { // A logo that finished loading before hydration never fires onLoad. const img = layerRef.current?.querySelector("img"); if (logo && img?.complete && img.naturalWidth) setLogoAccent({ src: logo, color: accentOf(img) }); api.current.paint(); }); return () => cancelAnimationFrame(id); }, [company, tagline, name, role, email, website, phone, location, showLogo, logo]); const aim = (e: PointerEvent<HTMLElement>) => { const s = sim.current; const rect = e.currentTarget.getBoundingClientRect(); const nx = Math.min(Math.max(((e.clientX - rect.left) / rect.width) * 2 - 1, -1.2), 1.2); const ny = Math.min(Math.max(1 - ((e.clientY - rect.top) / rect.height) * 2, -1.2), 1.2); const max = s.reduced ? 0 : (s.tilt * Math.PI) / 180; s.hover = true; s.lightK = 90; s.t.ry = nx * max; s.t.rx = -ny * max; s.t.lx = (nx * rect.width) / 2; s.t.ly = (ny * rect.height) / 2; s.t.lift = s.reduced ? 0 : s.pressed ? -0.6 : 1; s.t.hover = 1; api.current.kick(); }; // Press: the card dips toward the screen and a ring of light rolls out from // the press point. const press = (e: PointerEvent<HTMLElement>) => { const s = sim.current; s.pressed = true; aim(e); if (!s.ripple || s.reduced) return; const rect = e.currentTarget.getBoundingClientRect(); const rip = glRef.current?.face.uniforms.uRipple.value; rip?.set(e.clientX - rect.left - rect.width / 2, rect.height / 2 - (e.clientY - rect.top), 0, 1); s.rippleT = 0; api.current.kick(); }; const release = (e: PointerEvent<HTMLElement>) => { const s = sim.current; if (!s.pressed) return; s.pressed = false; aim(e); }; const leave = () => { const s = sim.current; s.hover = false; s.pressed = false; s.t.rx = s.t.ry = s.t.lift = s.t.hover = 0; s.t.lx = rootRef.current ? rootRef.current.offsetWidth * 0.22 : 0; s.t.ly = rootRef.current ? rootRef.current.offsetHeight * 0.62 : 0; api.current.kick(); }; const contacts = [website, email, phone].filter(Boolean) as string[]; return ( <article ref={rootRef} aria-label={company} {...rest} onPointerMove={(e) => { onPointerMove?.(e); aim(e); }} onPointerDown={(e) => { onPointerDown?.(e); press(e); }} onPointerUp={(e) => { onPointerUp?.(e); release(e); }} onPointerLeave={(e) => { onPointerLeave?.(e); leave(); }} className={cn("@container relative aspect-[1.75] touch-pan-y data-fallback:[background:var(--card-base)]", className)} style={ { width: `min(${width}px, 100%)`, borderRadius: radius, perspective: `${DISTANCE}px`, "--card-base": fill === "solid" ? fillColor : fill === "gradient" ? `linear-gradient(${fillAngle}deg, ${fillFrom}, ${fillTo})` : fill === "mesh" ? `linear-gradient(${fillAngle}deg, ${fillFrom}, ${fillTo}, ${fillHighlight})` : mat.base, ...style, } as CSSProperties } > <canvas ref={canvasRef} aria-hidden="true" className="pointer-events-none absolute" style={{ inset: -PAD, width: `calc(100% + ${PAD * 2}px)`, height: `calc(100% + ${PAD * 2}px)` }} /> {/* Real text for layout, screen readers and selection. The canvas paints it; this layer tilts with the card but stays invisible. */} <div ref={layerRef} className="absolute inset-0 flex flex-col justify-between p-[7cqw] in-data-gl:opacity-0" style={{ borderRadius: radius, color: mat.ink }} > <div className="flex items-start justify-between gap-[4cqw]"> <div className="flex min-w-0 items-center gap-[3cqw]"> {showLogo ? ( // eslint-disable-next-line @next/next/no-img-element <img data-ink="logo" src={logo} alt="" crossOrigin="anonymous" onLoad={(e) => { setLogoAccent({ src: logo ?? "", color: accentOf(e.currentTarget) }); api.current.paint(); }} onError={() => setFailedLogo(logo)} className="block h-[9cqw] w-auto max-w-[24cqw] shrink-0 object-contain" /> ) : ( <div data-ink="logo" aria-hidden="true" className="grid size-[9cqw] shrink-0 place-items-center rounded-[2.2cqw] text-[4.6cqw] font-semibold" style={{ background: tone, color: mat.base }} > {company.trim().charAt(0).toUpperCase()} </div> )} <div className="min-w-0"> <span data-ink="primary" className="block truncate text-[4.4cqw] leading-[1.25] font-semibold tracking-tight"> {company} </span> {tagline && ( <span data-ink="secondary" className="block truncate text-[2.9cqw] leading-[1.4]" style={{ color: mat.ink2 }}> {tagline} </span> )} </div> </div> {location && ( <span data-ink="secondary" className="block shrink-0 truncate pt-[0.6cqw] text-right text-[2.7cqw] leading-[1.4]" style={{ color: mat.ink2 }}> {location} </span> )} </div> <div className="flex items-end justify-between gap-[4cqw]"> <div className="min-w-0"> {name && ( <span data-ink="primary" className="block truncate text-[3.8cqw] leading-[1.3] font-medium tracking-tight"> {name} </span> )} {role && ( <span data-ink="secondary" className="block truncate text-[2.9cqw] leading-[1.4]" style={{ color: mat.ink2 }}> {role} </span> )} </div> {contacts.length > 0 && ( <div className="min-w-0 text-right" style={{ color: mat.ink2 }}> {contacts.map((line) => ( <span key={line} data-ink="secondary" className="block truncate text-[2.7cqw] leading-[1.45]"> {line} </span> ))} </div> )} </div> </div> </article> ); }
Adds the files to
components/uibrik/company-card and installs three. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/company-card.Props
CompanyCard- material
- Type "obsidian" | "metal" | "paper" | "glass"
- "obsidian"
- Card stock: soft-touch black with spot gloss, brushed graphite, cotton letterpress, or smoked acrylic.
- grain
- Type number
- 0.7
- Paper fibre, metal brushing or texture of the stock. Not used on glass.
- fill
- Type "stock" | "solid" | "gradient" | "mesh"
- "gradient"
- What's printed on the stock: its own colour, a solid, a gradient that ends in a soft glow, or a mesh of three colours. The material's finish stays on top.
- fillColor
- Type string
- "#1d3bff"
- Colour for the solid fill.
- fillFrom
- Type string
- "#01000a"
- Gradient and mesh start colour.
- fillTo
- Type string
- "#5a2ee8"
- Gradient and mesh end colour.
- fillHighlight
- Type string
- "#c8b8ff"
- Mesh highlight colour.
- fillAngle
- Type number
- 125
- Gradient direction, as in CSS: 135° runs top left to bottom right.
- fillGlow
- Type number
- 0.75
- Bloom of light where the gradient ends, or on the mesh highlight.
- fillOpacity
- Type number
- 0.85
- How opaque the fill is; lower lets the stock's own colour show through and softens it.
- fillNoise
- Type number
- 0.95
- Fine grain over the fill.
- thickness
- Type number
- 3
- Card thickness, seen on the edge. Also sets how deep the glass pattern floats.
- logoFinish
- Type "foil" | "holo" | "ink" | "blind"
- "foil"
- Hot foil, a holographic kinegram, the logo in its own colours, or relief with no ink.
- foil
- Type number
- 1
- Rainbow and sparkle in a foil or holo logo.
- relief
- Type number
- 1.3
- Depth of the emboss and deboss.
- overlay
- Type "none" | "holographic" | "glitter" | "holoGlitter" | "lenticular" | "pearl"
- "holoGlitter"
- A finish over the whole face: holographic film, prismatic glitter, both softly together (on hover, under the pointer), a lens sheet that flips patterns as it tilts, or pearl.
- overlayStrength
- Type number
- 1.65
- How strong the overlay is.
- holoSpread
- Type number
- 1
- Size of the holo glow around the light, or of the hover spot for holo glitter.
- saturation
- Type number
- 1
- How much colour the holo or pearl shows; 0 is silver.
- hiddenLogo
- Type boolean
- true
- A large, faint copy of the logo in the holo film, seen as the colours pass through it.
- glitterDensity
- Type number
- 1.25
- How many flakes there are.
- flakeSize
- Type number
- 3.5
- Size of each glitter flake.
- lensPitch
- Type number
- 3
- Width of each lenticular lens ridge.
- motif
- Type "contours" | "guilloche" | "none"
- "contours"
- Pattern seeded by the company name: topographic contours or a banknote rosette. Lenticular shows both.
- pattern
- Type number
- 0.85
- Strength of the pattern lines.
- lineDensity
- Type number
- 1.05
- How closely the pattern lines are spaced.
- petals
- Type number
- 0
- Petals in the guilloche rosette; 0 picks a count from the company name.
- company
- Type string
- "Halcyon"
- Company name. Also seeds the pattern.
- tagline
- Type string
- "Instruments for quiet work"
- Short line under the name.
- name
- Type string
- "Ava Moreno"
- Person on the card.
- role
- Type string
- "Head of Design"
- Their role.
- website
- Type string
- "halcyon.studio"
- Website.
- Type string
- "ava@halcyon.studio"
- Email address.
- phone
- Type string
- "+1 415 555 0142"
- Phone number.
- location
- Type string
- "San Francisco"
- City, top right.
- accent
- Type string
- "#5a2ee8"
- Foil, edge paint and glass glow. Left out, it is taken from the logo.
- lightColor
- Type string
- "#ffffff"
- Colour of the light that follows the pointer.
- tilt
- Type number
- 8
- Largest tilt toward the pointer.
- ripple
- Type boolean
- true
- Press to dip the card and send a ring of light across it.
- radius
- Type number
- 18
- Corner radius.
- width
- Type number
- 420
- Card width; it never grows past its container.
- logo
- Type string
- –
- Logo URL, ideally an SVG on a transparent background, same-origin or CORS-enabled. Without one, a monogram is stamped.
- className
- Type string
- –
- Classes for the card.