{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"offset-shadow-card","type":"registry:component","title":"Offset Shadow Card","description":"A neo-brutalist card on a hard shadow: it lifts 2px on hover and lands on the shadow when pressed. A flat drawn motif or a photo, a punched price tag, a sticker badge, 3px ink throughout.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/offset-shadow-card.tsx","content":"\"use client\";\n\nimport { useState } from \"react\";\nimport type { CSSProperties, ReactNode } from \"react\";\n\n/**\n * OffsetShadowCard — a neo-brutalist card on a hard, unblurred shadow.\n *\n * The card behaves like the kit's button: pointing at it lifts it 2px toward\n * you while the shadow grows from 5px to 7px (the shadow's far corner stays\n * put), and pressing it lands it on the shadow. 100ms linear, nothing eased.\n * Given href, the whole card is one link (the title's link is stretched over\n * the card), so there is one tab stop and one accessible name.\n *\n * The picture well takes a photograph, or draws a flat geometric motif in the\n * card's colour when there is none. Every line in the card, the motif's\n * included, is the same 3px ink.\n *\n * Type reads var(--font-display) for the title and var(--font-mono) for\n * labels, so a buyer's own next/font variables drop in.\n *\n * Needs Tailwind v4 (or v3.4+). No dependencies beyond React.\n */\n\nconst DISPLAY = 'var(--font-display, \"Archivo\", \"Archivo Black\", \"Arial Black\", \"Helvetica Neue\", Arial, sans-serif)';\nconst SANS = 'var(--font-sans, \"Geist\", \"Inter\", \"Helvetica Neue\", Arial, sans-serif)';\nconst MONO = 'var(--font-mono, \"Geist Mono\", ui-monospace, \"SFMono-Regular\", Menlo, Consolas, monospace)';\n\nconst FILLS = {\n  yellow: \"#ffd23f\",\n  coral: \"#ff6b6b\",\n  blue: \"#74b9ff\",\n  lime: \"#b4f462\",\n  pink: \"#ff5fa2\",\n  white: \"#ffffff\",\n  cream: \"#fffdf5\",\n} as const;\n\nexport type OffsetShadowColor = keyof typeof FILLS;\n\n/**\n * The kit's shadow ladder. A lift moves the card up-left 2px and grows the\n * shadow 2px, so the shadow's far corner stays put; a press lands the card on\n * the shadow. Written out whole so Tailwind can see every class.\n */\nconst DEPTH = {\n  sm: {\n    rest: \"shadow-[3px_3px_0_0_var(--osc-ink)] \",\n    hover: \"hover:-translate-x-[2px] hover:-translate-y-[2px] hover:shadow-[5px_5px_0_0_var(--osc-ink)] \",\n    down: \"translate-x-[3px] translate-y-[3px] shadow-none \",\n  },\n  md: {\n    rest: \"shadow-[5px_5px_0_0_var(--osc-ink)] \",\n    hover: \"hover:-translate-x-[2px] hover:-translate-y-[2px] hover:shadow-[7px_7px_0_0_var(--osc-ink)] \",\n    down: \"translate-x-[5px] translate-y-[5px] shadow-none \",\n  },\n  lg: {\n    rest: \"shadow-[8px_8px_0_0_var(--osc-ink)] \",\n    hover: \"hover:-translate-x-[2px] hover:-translate-y-[2px] hover:shadow-[10px_10px_0_0_var(--osc-ink)] \",\n    down: \"translate-x-[8px] translate-y-[8px] shadow-none \",\n  },\n} as const;\n\nconst HEX = /^#[0-9a-fA-F]{6}$/;\nconst LEVELS = [\"h2\", \"h3\", \"h4\"] as const;\n\nfunction own<T extends object>(map: T, key: string): key is Extract<keyof T, string> {\n  return Object.prototype.hasOwnProperty.call(map, key);\n}\n\n/** Links: strip tabs and newlines, refuse backslashes, allow http(s), mailto, tel and same-site paths. */\nfunction safeHref(raw: string) {\n  const v = raw.replace(/[\\t\\n\\r]/g, \"\").trim();\n  if (!v || v.includes(\"\\\\\")) return \"#\";\n  if (/^(https?:|mailto:|tel:)/i.test(v)) return v;\n  if (/^[/#?]/.test(v) && !/^\\/\\//.test(v)) return v;\n  return \"#\";\n}\n\n/** Images: the same guard, and only http(s) or same-site paths. */\nfunction safeSrc(raw: string) {\n  let v = \"\";\n  for (const ch of raw) if (ch.charCodeAt(0) > 31 && ch.charCodeAt(0) !== 127) v += ch;\n  v = v.trim();\n  if (!v || v.includes(\"\\\\\")) return \"\";\n  if (/^https?:\\/\\//i.test(v)) return v;\n  if (/^\\/(?!\\/)/.test(v) || /^\\.{1,2}\\//.test(v)) return v;\n  return \"\";\n}\n\nexport type OffsetShadowCardProps = {\n  title: string;\n  /** A mono label above the title. */\n  eyebrow?: string;\n  body?: ReactNode;\n  tags?: string[];\n  /** A hole-punched price tag pinned to the picture. */\n  price?: string;\n  /** A rotated sticker over the top-right corner, e.g. \"New\". */\n  badge?: string;\n  /** Mono text at the left of the footer row. */\n  meta?: string;\n  /** The footer's action label (shown when linked). */\n  action?: string;\n  /** Makes the whole card one link. */\n  href?: string;\n  image?: { src: string; alt: string };\n  /** The flat motif drawn when there is no image. */\n  motif?: \"orbit\" | \"stack\" | \"bars\";\n  /** The picture well's fill: a kit fill or any #rrggbb. */\n  color?: OffsetShadowColor | string;\n  /** Border, shadow and text colour. */\n  ink?: string;\n  /** The card's own ground. */\n  surface?: \"white\" | \"cream\";\n  /** Shadow depth on the kit's ladder: 3px, 5px or 8px. Give a featured card \"lg\". */\n  depth?: \"sm\" | \"md\" | \"lg\";\n  as?: \"h2\" | \"h3\" | \"h4\";\n  className?: string;\n};\n\nfunction Motif({ kind, ink }: { kind: \"orbit\" | \"stack\" | \"bars\"; ink: string }) {\n  const s = { stroke: ink, strokeWidth: 3, vectorEffect: \"non-scaling-stroke\" as const };\n  if (kind === \"stack\") {\n    return (\n      <svg aria-hidden viewBox=\"0 0 320 200\" preserveAspectRatio=\"xMidYMid slice\" className=\"absolute inset-0 size-full\">\n        {[0, 1, 2].map((i) => (\n          <g key={i} transform={\"translate(\" + (78 + i * 34) + \" \" + (40 + i * 26) + \")\"}>\n            <rect x=\"8\" y=\"8\" width=\"118\" height=\"78\" fill={ink} />\n            <rect x=\"0\" y=\"0\" width=\"118\" height=\"78\" fill={i === 2 ? \"#ffffff\" : i === 1 ? \"#fffdf5\" : ink} {...s} />\n            {i === 2 && <path d=\"M16 24H78M16 40H96M16 56H60\" {...s} />}\n          </g>\n        ))}\n      </svg>\n    );\n  }\n  if (kind === \"bars\") {\n    const hs = [46, 82, 64, 118, 96, 140];\n    return (\n      <svg aria-hidden viewBox=\"0 0 320 200\" preserveAspectRatio=\"xMidYMid slice\" className=\"absolute inset-0 size-full\">\n        {hs.map((h, i) => (\n          <g key={i}>\n            <rect x={58 + i * 36 + 5} y={172 - h + 5} width=\"24\" height={h} fill={ink} />\n            <rect x={58 + i * 36} y={172 - h} width=\"24\" height={h} fill={i === 3 ? ink : \"#ffffff\"} {...s} />\n          </g>\n        ))}\n        <path d=\"M34 172H290\" {...s} />\n      </svg>\n    );\n  }\n  return (\n    <svg aria-hidden viewBox=\"0 0 320 200\" preserveAspectRatio=\"xMidYMid slice\" className=\"absolute inset-0 size-full\">\n      <circle cx=\"214\" cy=\"96\" r=\"64\" fill={ink} />\n      <rect x=\"84\" y=\"62\" width=\"96\" height=\"96\" fill=\"#ffffff\" {...s} />\n      <circle cx=\"132\" cy=\"110\" r=\"22\" fill=\"none\" {...s} />\n      <path d=\"M40 176H280\" {...s} />\n    </svg>\n  );\n}\n\nexport function OffsetShadowCard({\n  title,\n  eyebrow,\n  body,\n  tags = [],\n  price,\n  badge,\n  meta,\n  action = \"Open\",\n  href,\n  image,\n  motif = \"orbit\",\n  color = \"yellow\",\n  ink = \"#000000\",\n  surface = \"white\",\n  depth = \"md\",\n  as = \"h3\",\n  className = \"\",\n}: OffsetShadowCardProps) {\n  const [pressed, setPressed] = useState(false);\n  const line = HEX.test(ink) ? ink : \"#000000\";\n  const fill = own(FILLS, color) ? FILLS[color] : HEX.test(color) ? color : FILLS.yellow;\n  const ground = surface === \"cream\" ? \"#fffdf5\" : \"#ffffff\";\n  const Heading = (LEVELS as readonly string[]).includes(as) ? as : \"h3\";\n  const src = image ? safeSrc(image.src) : \"\";\n  const link = href ? safeHref(href) : null;\n  const rule = \"3px solid \" + line;\n  const release = () => setPressed(false);\n  const d = own(DEPTH, depth) ? DEPTH[depth] : DEPTH.md;\n\n  const cls =\n    \"group relative flex flex-col text-left transition-[transform,translate,box-shadow] duration-100 ease-linear motion-reduce:transition-none \" +\n    \"has-[a:focus-visible]:[outline:3px_dashed_var(--osc-ink)] has-[a:focus-visible]:[outline-offset:6px] \" +\n    (link ? (pressed ? d.down : d.rest + d.hover) : d.rest) +\n    className;\n\n  return (\n    <article\n      className={cls}\n      style={{ \"--osc-ink\": line, border: rule, background: ground, color: line } as CSSProperties}\n      onPointerDown={(e) => {\n        if (link && e.button === 0) setPressed(true);\n      }}\n      onPointerUp={release}\n      onPointerLeave={release}\n      onPointerCancel={release}\n      onKeyDown={(e) => {\n        if (link && e.key === \"Enter\" && !e.repeat) setPressed(true);\n      }}\n      onKeyUp={release}\n      onBlur={release}\n    >\n      <div className=\"relative aspect-[16/10] overflow-hidden\" style={{ background: fill, borderBottom: rule }}>\n        {src ? (\n          // eslint-disable-next-line @next/next/no-img-element\n          <img src={src} alt={image?.alt ?? \"\"} loading=\"lazy\" decoding=\"async\" className=\"absolute inset-0 size-full object-cover\" />\n        ) : (\n          <Motif kind={motif} ink={line} />\n        )}\n        {price && (\n          <span\n            className=\"absolute bottom-4 left-4 inline-flex h-9 items-center gap-2.5 pl-2.5 pr-3.5 text-[15px] tabular-nums\"\n            style={{ background: \"#ffffff\", border: rule, color: line, fontFamily: DISPLAY, fontWeight: 800 }}\n          >\n            <span aria-hidden className=\"size-[11px] rounded-full\" style={{ border: rule, background: fill }} />\n            {price}\n          </span>\n        )}\n      </div>\n\n      {badge && (\n        <span\n          className=\"pointer-events-none absolute -right-3 -top-3 z-10 inline-flex h-8 rotate-[6deg] items-center px-3 text-[12px] uppercase tracking-[0.08em]\"\n          style={{ background: line, color: ground, fontFamily: MONO, fontWeight: 700 }}\n        >\n          {badge}\n        </span>\n      )}\n\n      <div className=\"flex flex-1 flex-col gap-3 px-5 pb-5 pt-4\">\n        {eyebrow && (\n          <p className=\"text-[12px] uppercase tracking-[0.1em]\" style={{ fontFamily: MONO, opacity: 0.7 }}>\n            {eyebrow}\n          </p>\n        )}\n        <Heading className=\"text-[26px] leading-[1.02] tracking-[-0.02em]\" style={{ fontFamily: DISPLAY, fontWeight: 800 }}>\n          {link ? (\n            <a href={link} className=\"outline-none focus-visible:![outline:none] after:absolute after:inset-0 after:content-['']\">\n              {title}\n            </a>\n          ) : (\n            title\n          )}\n        </Heading>\n        {body && (\n          <div className=\"line-clamp-3 text-[15.5px] leading-[1.5]\" style={{ fontFamily: SANS, opacity: 0.8 }}>\n            {body}\n          </div>\n        )}\n        {tags.length > 0 && (\n          <ul className=\"flex flex-wrap gap-2 pt-1\">\n            {tags.map((t) => (\n              <li\n                key={t}\n                className=\"inline-flex h-7 items-center px-2 text-[11.5px] uppercase tracking-[0.06em]\"\n                style={{ border: rule, fontFamily: MONO, fontWeight: 600 }}\n              >\n                {t}\n              </li>\n            ))}\n          </ul>\n        )}\n      </div>\n\n      {(meta || link) && (\n        <div className=\"flex h-12 items-center justify-between gap-4 px-5\" style={{ borderTop: rule }}>\n          <span className=\"truncate text-[12px] uppercase tracking-[0.08em]\" style={{ fontFamily: MONO, opacity: 0.7 }}>\n            {meta}\n          </span>\n          {link && (\n            <span aria-hidden className=\"inline-flex shrink-0 items-center gap-2 text-[15px]\" style={{ fontFamily: DISPLAY, fontWeight: 800 }}>\n              {action}\n              <svg\n                width=\"18\"\n                height=\"14\"\n                viewBox=\"0 0 18 14\"\n                fill=\"none\"\n                className=\"transition-transform duration-100 ease-linear group-hover:translate-x-[3px] motion-reduce:transition-none\"\n              >\n                <path d=\"M0 7H15M9 1.5L15 7L9 12.5\" stroke=\"currentColor\" strokeWidth=\"3\" strokeLinecap=\"square\" />\n              </svg>\n            </span>\n          )}\n        </div>\n      )}\n    </article>\n  );\n}\n","type":"registry:component","target":"components/viberdy/offset-shadow-card.tsx"}],"meta":{"tier":"free","tags":["card","neo-brutalism","brutalist","hard shadow","product card","blog card","price tag"],"href":"https://viberdy.dev/library/offset-shadow-card","aiPrompt":"Create a React + Tailwind component, OffsetShadowCard: a neo-brutalist card on a hard, unblurred shadow that lifts on hover and lands on its shadow when pressed.\n\nProps (export OffsetShadowCardProps): title, eyebrow? (mono label), body?, tags?, price? (a hole-punched price tag pinned to the picture's bottom-left), badge? (a rotated black sticker over the top-right corner), meta? (mono, footer left), action (default \"Open\", shown with an arrow when linked), href? (the whole card becomes one link; sanitise: strip tabs and newlines, refuse backslashes, allow http(s), mailto, tel and paths starting / # ? but not //), image? { src, alt } (strip control characters by char code, refuse backslashes, allow http(s) and same-site paths only), motif (\"orbit\" | \"stack\" | \"bars\", drawn when there is no image), color (the picture well: yellow #ffd23f, coral #ff6b6b, blue #74b9ff, lime #b4f462, pink #ff5fa2, white, cream, or a regex-validated hex; look names up by own key only), ink (#000), surface (\"white\" | \"cream\" #fffdf5), depth (\"sm\" | \"md\" | \"lg\"), as (\"h2\" | \"h3\" | \"h4\", allowlisted at run time), className.\n\nKit grammar: every border and line is 3px of the ink, set inline so no global border-colour rule can repaint it; square corners; hard shadows in the ink on the kit's ladder, depth sm 3px, md 5px (default), lg 8px for a featured card. Linked cards lift 2px up-left on hover while the shadow grows 2px (the shadow's far corner never moves) and, while pressed, translate by the full depth with no shadow, landing on it. Drive the press from state (pointer down with the primary button; Enter on the link) and release on pointer up, leave, cancel, key up and blur. Only transform, translate and box-shadow transition (Tailwind v4 translate utilities set the translate property), 100ms linear; none under reduced motion. Write each depth's classes out whole so Tailwind sees them.\n\nLayout: an article with the ink border and a white or cream ground. A 16:10 picture well in the colour with a 3px bottom rule, holding the image (object-cover, lazy) or an SVG motif drawn with the same 3px ink (vector-effect non-scaling-stroke, preserveAspectRatio slice): \"orbit\" a black disc beside a white square with a ring and a baseline, \"stack\" three offset cards each on its own hard shadow, \"bars\" six white bars on hard shadows with one filled bar and a baseline. The price tag: white, 3px border, a small punched hole in the well's colour, the display face at 800. The badge: ink ground, cream mono text, rotated 6 degrees, pointer-events none. Then the eyebrow in 12px mono caps, the title in var(--font-display, \"Archivo\", \"Archivo Black\", \"Arial Black\", …) at 800, 26px, leading 1.02, tracking -0.02em (its link stretched over the whole card with an after pseudo-element), the body in var(--font-sans, …) at 15.5px/1.5 clamped to three lines, tags as 3px-bordered mono chips, and a full-width footer row under a 3px rule with the meta and the action (an arrow drawn in 3px strokes that steps 3px on hover; the action is aria-hidden because the link's name is the title). Keyboard focus on the link draws a 3px dashed outline 6px outside the card via :has.\n\nWrite the focus-ring declarations as important utilities (for example focus-visible:![outline-style:dashed], focus-visible:![outline-offset:4px], focus-visible:![border-radius:0]) and keep the ring colour inline, so an unlayered global :focus-visible rule in the host app cannot replace them.\n\nOutput one self-contained \"use client\" TSX file with no dependencies beyond React. Build strings with + rather than template literals.","license":"https://viberdy.dev/license"}}