Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

NDA badge

New

A View Work pill whose eye follows the pointer, and shuts when the work is under NDA.

nda-badge/nda-badge.tsx
Live · interactive

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

When to use

  • A project card in a portfolio or a case list, where some of the work can’t be shown.
  • Telling a visitor why a card won’t open at the moment they reach for it, not after a click.
  • A grid that mixes open and locked work: the same pill on every card, only the locked ones shut.
  • A playful page where a watching eye fits the tone and invites the pointer over.

When not to use

  • Real access control. The pill only says the work is private; protect the work itself on the server.
  • Work behind a login or a password. Use a sign-in button that says so, and lets the person in.
  • A dense table or a list of many rows: dozens of eyes following the pointer are noise. Use a quiet lock icon or a Private tag.
  • A sober product UI (billing, settings, admin). Use a plain button and a disabled state with a tooltip.
  • Touch-first pages alone: with no hover, the shut eye shows only for a moment after a tap.

You can add this component with the shadcn CLI or by hand:

Into components/nda-badge of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/nda-badge.json

Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

Usage

project-cover.tsx
import { NdaBadge } from './nda-badge';export function ProjectCover({ locked }: { locked: boolean }) {  return <NdaBadge locked={locked} href="/work/atlas" autoplay={false} />;}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

The pill that sits on a project card. Its eye watches the pointer wherever it goes on the page; hover or focus a locked one and the eye closes, the pill blushes red, and its words change to Under NDA. Both labels share one cell, so the pill never changes width.

  • The eye is HTML layers around two still drawings: the open eye with its pupil (a disc of its own), and the shut eye, a single curve. The pupil moves by a transform and the eyes swap by squashing and fading their boxes, so no SVG is ever restyled.
  • The pupil leans towards the pointer anywhere on the page, up to about 2 px: fully within 200 px of the eye, less and less out to 300 px. It is worked out once per frame while a mouse moves (never for touch) and eases there over 130 ms.
  • Hovering, focusing or tapping a locked pill shuts the eye: the open eye flattens and fades, the shut one opens out 50 ms later, and the colours cross over in 220 ms.
  • Both labels sit in the same grid cell, so the pill keeps the width of the longer one; one slides up and out as the other slides in.
  • The demo keeps a beat: the pointer reaches a new stop every 693 ms in a 440 ms flight the compositor plays, and the pupil plays its own animation through the same points on the same clock.
  • Every colour is a light-dark() token on the root, so the badge follows the page’s color-scheme, or the colorScheme prop. At night the eye turns light with a near-black pupil, and the NDA blush becomes a deep wine with a coral-red label.
PropTypeDefaultDescription
lockedbooleantrueThe work is under NDA: hovering or focusing the pill shuts the eye and says so.
labelstring'View Work'The pill’s words.
lockedLabelstring'Under NDA'What a locked pill says when hovered or focused.
defaultClosedbooleanfalseA locked pill starts shut, as if hovered, until the pointer or the focus leaves it. Turns the demo off.
gaze{ x: number; y: number }—Pins the pupil, looking at a point this many px from the eye’s middle (at the pill’s 13 px type); it then ignores the pointer, and the demo is off.
hrefstring—Where an open pill leads; without it, an open pill is a button that calls onOpen.
onOpen() => void—Called when an open pill without a link is pressed.
autoplaybooleantrueWhile on screen and untouched, a demo pointer wanders around the pill and hovers it.
colorScheme'light' | 'dark' | 'system'—Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
classNamestring—Extra class for the root.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

nda-badge.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useEffectEvent,  useRef,  useState,  type PointerEvent as ReactPointerEvent,} from 'react';import { DemoPointer, runDemo, type Point } from './demo-pointer';import { useAutoplay } from './use-autoplay';import styles from './nda-badge.module.css';/* * NDA badge: the "View Work" pill of a project card. Its eye watches the pointer; on work under * a non-disclosure agreement, hovering or focusing the pill shuts the eye and the pill turns red: * "Under NDA". * * - The eye is drawn in HTML layers around two still drawings: the open eye with its pupil, a *   round box of its own, and the shut eye, a single curve. The pupil moves by a transform, and *   the eyes swap by squashing and fading their boxes, so no SVG is ever restyled. * - The pupil leans towards the pointer, wherever it is on the page: up to about 2 px, all the way
nda-badge.module.css
/* NDA badge (nda-badge.tsx): a white stage with the pill in its middle, or at night a near-black   one with the pill a step lighter. The pill's sizes are in em of its 13 px type, so the stage can   draw it larger from its own width (a container query).   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes   the color-scheme it inherits from the page (light unless the page says otherwise), or the one   its colorScheme prop sets (data-color-scheme).   Memory. The stage is one layout boundary, sized from outside (its width and its proportions),   so nothing inside it lays out the page. The pill paints on a small layer of its own, so its   colour change never repaints the page around it; the pupil and the demo pointer move by   transforms. */.root {  /* The stage, and the pill on it. */  --surface: #fff;  --surface-2: #f3f3f3;  /* The words and the open eye. */  --ink: #17191d;  /* The pupil: the stage seen through the eye, white by day and near-black at night. */  --pupil: #fff;  /* The stage's edge, and the pill's. */  --line: rgb(0 0 0 / 0.06);  --edge: rgb(17 17 17 / 0.05);
demo-pointer.tsx
'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, or * glide the pointer somewhere. Stopping the demo (the reader takes over, or it scrolls away) ends * the step in progress at once: its promise rejects, which ends the script right there, and the * pointer fades out. * * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame. * The points of the flight are handed to whoever wants to move along with it (the eye of the * badge, which plays its own animation on the same clock). The arrow and the hand are HTML boxes * that swap by their visibility: a change of style inside an SVG would lay the SVG out again. */export type Point = { x: number; y: number };/** A glide's path and timing, for an animation that plays along with it. */export type Flight = { points: Point[]; timing: KeyframeAnimationOptions };export type Demo = {  /** Resolves after `ms`. */  wait: (ms: number) => Promise<void>;
use-autoplay.ts
'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) {  const ref = useRef<T>(null);  const [visible, setVisible] = useState(false);  const [reduced, setReduced] = useState(true);  const [taken, setTaken] = useState(false);  const timer = useRef(0);  useEffect(() => {    const element = ref.current;    if (!element) return;    let onScreen = false;    const update = () => setVisible(onScreen && !document.hidden);    const observer = new IntersectionObserver(      ([entry]) => {        onScreen = entry.isIntersecting;
  • An open pill with an href is a link, and without one a button. A locked pill is a button with aria-disabled, named with both labels (“View Work, Under NDA”).
  • Focus shuts the eye just as hover does, with a visible focus ring. Pressing a locked pill (key, click or tap) announces why it doesn’t open, in a polite live region.
  • The eye is decorative (aria-hidden), and the visible label swap is hidden from screen readers, which hear the pill’s name instead.
  • Under reduced motion the pupil stays centred, the swaps are instant and the demo never plays.