ns-ui
uiaccordion-latchsource: ok · hit
Accordion Latch
An accordion whose sections latch shut with a small SVG hasp-and-staple on each closed header: opening swings the hasp off its staple, lifts the lid 2px, then unfolds the content. Closing reverses and the hasp settles back down with a bounce.
npx shadcn add "https://design.helpmarq.com/r/accordion-latch.json"free
Where it goes
navbar
Interaction
accordion
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Files1 · source cached 10/14/2026, 11:20:03 AM
registry/core/accordion-latch/component.tsx
"use client";
import { useId, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react";
// ---------------------------------------------------------------------------
// HaspFold — an accordion whose sections latch shut with a visible hasp.
// Each closed header shows a small SVG hasp-and-staple on its right edge:
// a fixed U-shaped staple bracket and a hinged strap+loop that, at rest,
// hooks down over it. The whole open/close choreography is staged with
// plain CSS transitions keyed off one `data-state="open"|"closed"`
// attribute per stage (hasp / lid / content-rows) — no rAF loop, no
// per-frame React state — each stage just carries its own duration and
// transition-delay so the same state flip plays four moments in order:
// open: hasp swings up off the staple (spring overshoot, 0ms delay)
// -> the header ("lid") lifts 2px (starts as the hasp is
// settling) -> the content unfolds via the codebase's
// grid-template-rows 0fr->1fr trick, ease-out-expo, fading
// upward 4px (starts as the lid finishes lifting).
// close: reverse order — content folds first, then the lid drops,
// then the hasp drops back over the staple with a bouncier
// cubic-bezier so it reads as a settle rather than a snap.
// Hover on a closed header slides the hasp 1px via a plain CSS :hover
// rule (no JS) — "testing the latch." Single-open by default (opening
// one closes any other); `multiple` allows independent panels. Headers
// are real <button> elements with a roving tabindex (only the active
// header is in the tab sequence; ArrowUp/Down/Home/End move focus,
// matching the dropdown-drape pattern), aria-expanded + aria-controls, and
// a <div role="region" aria-labelledby> panel per the standard
// disclosure pattern. prefers-reduced-motion collapses every stage's
// duration/delay to zero, so open/close still happens (nothing gets
// stuck) but as a single instant snap, hasp included.
// ---------------------------------------------------------------------------
export interface HaspFoldItem {
id: string;
title: ReactNode;
content: ReactNode;
}
export interface HaspFoldProps {
/** the accordion sections, in order */
items: HaspFoldItem[];
/** Allow more than one section open at once. Default false (single-open). */
multiple?: boolean;
/** Initially open item ids. */
defaultOpen?: string[];
/** extra classes merged onto the rendered root element */
className?: string;
}
function HaspIcon({ open }: { open: boolean }) {
return (
<svg
width="16"
height="28"
viewBox="0 0 16 28"
aria-hidden="true"
focusable="false"
className="ns-hf-icon"
>
<path
d="M4.5 17.5 v3.6 a3 3 0 0 0 6 0 v-3.6"
fill="none"
stroke="var(--ns-muted)"
strokeWidth="1.4"
strokeLinecap="round"
/>
<g className="ns-hf-hasp" data-state={open ? "open" : "closed"}>
<circle cx="8" cy="4.5" r="1.5" fill="var(--ns-muted)" />
<line x1="8" y1="4.5" x2="8" y2="19.5" stroke="var(--foreground)" strokeWidth="1.5" strokeLinecap="round" />
<rect
x="4.6"
y="16.5"
width="6.8"
height="6"
rx="2.4"
fill="none"
stroke="var(--foreground)"
strokeWidth="1.5"
/>
</g>
</svg>
);
}
export function HaspFold({ items, multiple = false, defaultOpen = [], className = "" }: HaspFoldProps) {
const [openIds, setOpenIds] = useState<Set<string>>(() => new Set(defaultOpen));
// Roving tabindex is a single active index, independent of which panels
// are open — with `multiple`, more than one section can be open at once,
// and deriving tabIndex from open state would then put every open header
// in the tab sequence simultaneously instead of just one.
const [activeIndex, setActiveIndex] = useState(0);
const headerRefs = useRef<Array<HTMLButtonElement | null>>([]);
const baseId = useId().replace(/:/g, "");
const toggle = (id: string) => {
setOpenIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
if (!multiple) next.clear();
next.add(id);
}
return next;
});
};
const onHeaderKeyDown = (e: ReactKeyboardEvent<HTMLButtonElement>, index: number) => {
const count = items.length;
let next = -1;
if (e.key === "ArrowDown") next = (index + 1) % count;
else if (e.key === "ArrowUp") next = (index - 1 + count) % count;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = count - 1;
if (next >= 0) {
e.preventDefault();
setActiveIndex(next);
headerRefs.current[next]?.focus();
}
};
return (
<div className={`ns-hf-root ${className}`}>
<style>{CSS}</style>
{items.map((item, i) => {
const isOpen = openIds.has(item.id);
const headerId = `${baseId}-h-${item.id}`;
const panelId = `${baseId}-p-${item.id}`;
const state = isOpen ? "open" : "closed";
return (
<div key={item.id} className="ns-hf-section" data-state={state}>
<h3 className="ns-hf-h3">
<button
ref={(el) => {
headerRefs.current[i] = el;
}}
type="button"
id={headerId}
aria-expanded={isOpen}
aria-controls={panelId}
className="ns-hf-header"
data-state={state}
tabIndex={i === activeIndex ? 0 : -1}
onClick={() => {
setActiveIndex(i);
toggle(item.id);
}}
onFocus={() => setActiveIndex(i)}
onKeyDown={(e) => onHeaderKeyDown(e, i)}
>
<span className="ns-hf-title">{item.title}</span>
<HaspIcon open={isOpen} />
</button>
</h3>
<div
id={panelId}
role="region"
aria-labelledby={headerId}
className="ns-hf-rows"
data-state={state}
>
<div className="ns-hf-rows-inner">
<div className="ns-hf-content" data-state={state}>
{item.content}
</div>
</div>
</div>
</div>
);
})}
</div>
);
}
const CSS = `
.ns-hf-root{display:flex;flex-direction:column;border:1px solid var(--border);border-radius:12px;overflow:hidden;background:var(--background);}
.ns-hf-section + .ns-hf-section{border-top:1px solid var(--border);}
.ns-hf-h3{margin:0;}
.ns-hf-header{
position:relative;
display:flex;
width:100%;
align-items:center;
justify-content:space-between;
gap:12px;
padding:14px 40px 14px 16px;
border:0;
background:var(--background);
color:var(--foreground);
font-size:14px;
text-align:left;
cursor:pointer;
transform:translateY(0);
transition:transform 90ms ease-out;
transition-delay:90ms;
}
.ns-hf-header[data-state="open"]{
transform:translateY(-2px);
transition-delay:130ms;
}
.ns-hf-header:hover{background:color-mix(in oklab, var(--ns-muted) 8%, var(--background));}
.ns-hf-header:focus-visible{outline:2px solid var(--ns-accent);outline-offset:-2px;}
.ns-hf-title{flex:1;min-width:0;}
.ns-hf-icon{position:absolute;right:12px;top:50%;transform:translateY(-50%);}
.ns-hf-hasp{transform-origin:8px 4.5px;transform:rotate(0deg);
transition:transform 220ms cubic-bezier(0.68,-0.55,0.34,1.55);transition-delay:180ms;}
.ns-hf-hasp[data-state="open"]{transform:rotate(-58deg);
transition:transform 200ms cubic-bezier(0.34,1.56,0.64,1);transition-delay:0ms;}
.ns-hf-header[data-state="closed"]:hover .ns-hf-hasp{transform:translateX(-1.5px);transition:transform 120ms ease-out;}
.ns-hf-rows{
display:grid;
grid-template-rows:0fr;
transition:grid-template-rows 220ms cubic-bezier(0.4,0,0.2,1);
transition-delay:0ms;
}
.ns-hf-rows[data-state="open"]{
grid-template-rows:1fr;
transition:grid-template-rows 300ms cubic-bezier(0.16,1,0.3,1);
transition-delay:190ms;
}
.ns-hf-rows-inner{overflow:hidden;}
.ns-hf-content{
padding:0 16px 16px 16px;
color:var(--ns-muted);
font-size:13px;
line-height:1.6;
opacity:0;
transform:translateY(4px);
transition:opacity 120ms ease, transform 120ms ease;
transition-delay:0ms;
}
.ns-hf-content[data-state="open"]{
opacity:1;
transform:translateY(0);
transition:opacity 220ms ease, transform 220ms cubic-bezier(0.16,1,0.3,1);
transition-delay:220ms;
}
@media (prefers-reduced-motion: reduce){
.ns-hf-header,.ns-hf-hasp,.ns-hf-rows,.ns-hf-content{
transition-duration:0ms !important;
transition-delay:0ms !important;
}
}
`;