Target Lock
import { useEffect, useRef, useState } from 'react';
const css = `
@keyframes btn-target-lock-drift {
0%, 100% { translate: 0 0; }
25% { translate: 3px -2px; }
50% { translate: -2px 3px; }
75% { translate: -3px -1px; }
}
@keyframes btn-target-lock-caret { 50% { opacity: 0; } }
.btn-target-lock {
position: relative;
display: inline-grid;
place-items: center;
padding: 34px 26px 22px;
}
.btn-target-lock__btn {
position: relative;
padding: 0.75rem 1.8rem;
border: 1px solid light-dark(#dbdde1, #2e3035);
border-radius: 4px;
background: light-dark(#f3f4f7, #1a1c1f);
color: light-dark(#42454c, #e5e6e9);
font-family: inherit;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.2em;
text-transform: uppercase;
cursor: pointer;
transition: color 0.3s ease, border-color 0.3s ease;
}
.btn-target-lock__btn:focus-visible { outline: none; }
.btn-target-lock__frame {
position: absolute;
left: 26px;
right: 26px;
top: 34px;
bottom: 22px;
pointer-events: none;
transform: scale(1.45) rotate(8deg);
opacity: 0.35;
transition: transform 0.55s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease;
animation: btn-target-lock-drift 3.2s ease-in-out infinite;
}
.btn-target-lock__corner {
position: absolute;
width: 12px;
height: 12px;
border: 0 solid light-dark(#373a40, #f1f2f3);
}
.btn-target-lock__corner:nth-child(1) { top: -7px; left: -7px; border-top-width: 2px; border-left-width: 2px; }
.btn-target-lock__corner:nth-child(2) { top: -7px; right: -7px; border-top-width: 2px; border-right-width: 2px; }
.btn-target-lock__corner:nth-child(3) { bottom: -7px; left: -7px; border-bottom-width: 2px; border-left-width: 2px; }
.btn-target-lock__corner:nth-child(4) { bottom: -7px; right: -7px; border-bottom-width: 2px; border-right-width: 2px; }
.btn-target-lock__tick {
position: absolute;
background: light-dark(#50535b, #c6c8cd);
opacity: 0;
transition: opacity 0.2s ease 0.35s;
}
.btn-target-lock__tick--t { top: -12px; left: calc(50% - 0.5px); width: 1px; height: 7px; }
.btn-target-lock__tick--b { bottom: -12px; left: calc(50% - 0.5px); width: 1px; height: 7px; }
.btn-target-lock__tick--l { left: -14px; top: calc(50% - 0.5px); width: 7px; height: 1px; }
.btn-target-lock__tick--r { right: -14px; top: calc(50% - 0.5px); width: 7px; height: 1px; }
.btn-target-lock__readout {
position: absolute;
top: 8px;
left: 18px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 10px;
letter-spacing: 0.12em;
color: light-dark(#5f626a, #a7abb2);
white-space: pre;
pointer-events: none;
}
.btn-target-lock__readout i { font-style: normal; animation: btn-target-lock-caret 0.8s steps(1) infinite; }
.btn-target-lock[data-locked] .btn-target-lock__frame {
transform: none;
opacity: 1;
animation: none;
}
.btn-target-lock[data-locked] .btn-target-lock__tick { opacity: 1; }
.btn-target-lock[data-locked] .btn-target-lock__btn { color: light-dark(#373a40, #f1f2f3); border-color: light-dark(#6d7179, #7d8087); }
.btn-target-lock[data-locked] .btn-target-lock__readout { color: light-dark(#373a40, #f1f2f3); }
@media (prefers-reduced-motion: reduce) {
.btn-target-lock__frame { animation: none; transition: opacity 0.2s ease; }
}
`;
const LOCKED = 'TGT 07 · LOCKED';
const IDLE = 'TGT 07 · ACQUIRING';
export function TargetLockButton() {
const [locked, setLocked] = useState(false);
const [text, setText] = useState(IDLE);
const timer = useRef(0);
// Retype the readout whenever the lock state flips.
useEffect(() => {
const target = locked ? LOCKED : IDLE;
let n = 'TGT 07 · '.length;
setText(target.slice(0, n));
window.clearInterval(timer.current);
timer.current = window.setInterval(() => {
n += 1;
setText(target.slice(0, n));
if (n >= target.length) window.clearInterval(timer.current);
}, 38);
return () => window.clearInterval(timer.current);
}, [locked]);
return (
<>
<style>
{css}
</style>
<div
className="btn-target-lock"
data-locked={locked ? '' : undefined}
onPointerEnter={() => setLocked(true)}
onPointerLeave={() => setLocked(false)}
>
<span className="btn-target-lock__readout" aria-hidden="true">
{text}
<i>_</i>
</span>
<button
type="button"
className="btn-target-lock__btn"
onFocus={() => setLocked(true)}
onBlur={() => setLocked(false)}
>
Engage
</button>
<span className="btn-target-lock__frame" aria-hidden="true">
<span className="btn-target-lock__corner" />
<span className="btn-target-lock__corner" />
<span className="btn-target-lock__corner" />
<span className="btn-target-lock__corner" />
<span className="btn-target-lock__tick btn-target-lock__tick--t" />
<span className="btn-target-lock__tick btn-target-lock__tick--b" />
<span className="btn-target-lock__tick btn-target-lock__tick--l" />
<span className="btn-target-lock__tick btn-target-lock__tick--r" />
</span>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initBtnTargetLock(el) on the mounted element instead.
<div class="btn-target-lock">
<span class="btn-target-lock__readout" aria-hidden="true">TGT 07 · ACQUIRING<i>_</i></span>
<button type="button" class="btn-target-lock__btn">Engage</button>
<span class="btn-target-lock__frame" aria-hidden="true">
<span class="btn-target-lock__corner"></span>
<span class="btn-target-lock__corner"></span>
<span class="btn-target-lock__corner"></span>
<span class="btn-target-lock__corner"></span>
<span class="btn-target-lock__tick btn-target-lock__tick--t"></span>
<span class="btn-target-lock__tick btn-target-lock__tick--b"></span>
<span class="btn-target-lock__tick btn-target-lock__tick--l"></span>
<span class="btn-target-lock__tick btn-target-lock__tick--r"></span>
</span>
</div>@keyframes btn-target-lock-drift {
0%, 100% { translate: 0 0; }
25% { translate: 3px -2px; }
50% { translate: -2px 3px; }
75% { translate: -3px -1px; }
}
@keyframes btn-target-lock-caret { 50% { opacity: 0; } }
.btn-target-lock {
position: relative;
display: inline-grid;
place-items: center;
padding: 34px 26px 22px;
}
.btn-target-lock__btn {
position: relative;
padding: 0.75rem 1.8rem;
border: 1px solid light-dark(#dbdde1, #2e3035);
border-radius: 4px;
background: light-dark(#f3f4f7, #1a1c1f);
color: light-dark(#42454c, #e5e6e9);
font-family: inherit;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.2em;
text-transform: uppercase;
cursor: pointer;
transition: color 0.3s ease, border-color 0.3s ease;
}
.btn-target-lock__btn:focus-visible { outline: none; }
.btn-target-lock__frame {
position: absolute;
left: 26px;
right: 26px;
top: 34px;
bottom: 22px;
pointer-events: none;
transform: scale(1.45) rotate(8deg);
opacity: 0.35;
transition: transform 0.55s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease;
animation: btn-target-lock-drift 3.2s ease-in-out infinite;
}
.btn-target-lock__corner {
position: absolute;
width: 12px;
height: 12px;
border: 0 solid light-dark(#373a40, #f1f2f3);
}
.btn-target-lock__corner:nth-child(1) { top: -7px; left: -7px; border-top-width: 2px; border-left-width: 2px; }
.btn-target-lock__corner:nth-child(2) { top: -7px; right: -7px; border-top-width: 2px; border-right-width: 2px; }
.btn-target-lock__corner:nth-child(3) { bottom: -7px; left: -7px; border-bottom-width: 2px; border-left-width: 2px; }
.btn-target-lock__corner:nth-child(4) { bottom: -7px; right: -7px; border-bottom-width: 2px; border-right-width: 2px; }
.btn-target-lock__tick {
position: absolute;
background: light-dark(#50535b, #c6c8cd);
opacity: 0;
transition: opacity 0.2s ease 0.35s;
}
.btn-target-lock__tick--t { top: -12px; left: calc(50% - 0.5px); width: 1px; height: 7px; }
.btn-target-lock__tick--b { bottom: -12px; left: calc(50% - 0.5px); width: 1px; height: 7px; }
.btn-target-lock__tick--l { left: -14px; top: calc(50% - 0.5px); width: 7px; height: 1px; }
.btn-target-lock__tick--r { right: -14px; top: calc(50% - 0.5px); width: 7px; height: 1px; }
.btn-target-lock__readout {
position: absolute;
top: 8px;
left: 18px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 10px;
letter-spacing: 0.12em;
color: light-dark(#5f626a, #a7abb2);
white-space: pre;
pointer-events: none;
}
.btn-target-lock__readout i { font-style: normal; animation: btn-target-lock-caret 0.8s steps(1) infinite; }
.btn-target-lock[data-locked] .btn-target-lock__frame {
transform: none;
opacity: 1;
animation: none;
}
.btn-target-lock[data-locked] .btn-target-lock__tick { opacity: 1; }
.btn-target-lock[data-locked] .btn-target-lock__btn { color: light-dark(#373a40, #f1f2f3); border-color: light-dark(#6d7179, #7d8087); }
.btn-target-lock[data-locked] .btn-target-lock__readout { color: light-dark(#373a40, #f1f2f3); }
@media (prefers-reduced-motion: reduce) {
.btn-target-lock__frame { animation: none; transition: opacity 0.2s ease; }
}function initBtnTargetLock(root) {
var btn = root.querySelector('.btn-target-lock__btn');
var readout = root.querySelector('.btn-target-lock__readout');
if (!btn || !readout) return;
var PREFIX = 'TGT 07 · ';
var text = readout.firstChild;
var timer = 0;
// Retype the readout whenever the lock state flips.
function set(locked) {
root.toggleAttribute('data-locked', locked);
var target = PREFIX + (locked ? 'LOCKED' : 'ACQUIRING');
var n = PREFIX.length;
clearInterval(timer);
text.textContent = target.slice(0, n);
timer = setInterval(function () {
n += 1;
text.textContent = target.slice(0, n);
if (n >= target.length) clearInterval(timer);
}, 38);
}
function lock() { set(true); }
function release() { set(false); }
root.addEventListener('pointerenter', lock);
root.addEventListener('pointerleave', release);
btn.addEventListener('focus', lock);
btn.addEventListener('blur', release);
return function () {
clearInterval(timer);
root.removeEventListener('pointerenter', lock);
root.removeEventListener('pointerleave', release);
btn.removeEventListener('focus', lock);
btn.removeEventListener('blur', release);
};
}
document.querySelectorAll('.btn-target-lock').forEach(initBtnTargetLock);Goal: Create a "Target Lock" button.
Visual behavior: HUD targeting brackets drift loosely outside the button. On hover or focus they snap onto it with a springy lock, crosshair ticks appear, and a readout above retypes itself from ACQUIRING to LOCKED.
Technique:
- Four L-shaped corner brackets (two borders each) and four crosshair ticks sit in a frame positioned over the button.
- Unlocked, the frame is scaled 1.45, rotated 8deg, faint and drifting on a small translate keyframe; locked, it transitions to none on an overshooting cubic-bezier and the ticks fade in after it.
- A data-locked attribute set on pointerenter / focus drives every locked style, including the brighter label and border.
- The readout keeps its "TGT 07 · " prefix and types the rest of the new word every 38ms on an interval, with a blinking caret.
Accessibility: A real <button> with a visible label; decoration is aria-hidden. Focus locks it like hover. Under prefers-reduced-motion the brackets neither drift nor spring; they just fade in place.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Target Lock" button.
Visual behavior: HUD targeting brackets drift loosely outside the button. On hover or focus they snap onto it with a springy lock, crosshair ticks appear, and a readout above retypes itself from ACQUIRING to LOCKED.
Technique:
- Four L-shaped corner brackets (two borders each) and four crosshair ticks sit in a frame positioned over the button.
- Unlocked, the frame is scaled 1.45, rotated 8deg, faint and drifting on a small translate keyframe; locked, it transitions to none on an overshooting cubic-bezier and the ticks fade in after it.
- A data-locked attribute set on pointerenter / focus drives every locked style, including the brighter label and border.
- The readout keeps its "TGT 07 · " prefix and types the rest of the new word every 38ms on an interval, with a blinking caret.
Accessibility: A real <button> with a visible label; decoration is aria-hidden. Focus locks it like hover. Under prefers-reduced-motion the brackets neither drift nor spring; they just fade in place.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
HUD targeting brackets drift loosely outside the button. On hover or focus they snap onto it with a springy lock, crosshair ticks appear, and a readout above retypes itself from ACQUIRING to LOCKED.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- hover, focus, spring, state change
- Techniques
- keyframes, css transition, text animation, interval
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real <button> with a visible label; decoration is aria-hidden. Focus locks it like hover. Under prefers-reduced-motion the brackets neither drift nor spring; they just fade in place.