Components & interactionsFullscreen menu overlay
Curtain index
A full-area curtain opens into an oversized editorial index, with a short stagger between entries.

Open Index, select a section, or close with Escape
Subframe Original · MIT
Add to your project
Component source
Standalone React component. Styles are included.
React only. No additional packages.
Render the default export inside a container with a defined height. Its optional active prop stops movement when false. This example works with React 19; Next.js projects can use it as a client component.
View source code
/**
* MIT License
*
* Copyright (c) 2026 Subframe
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
"use client"
import { type CSSProperties, useEffect, useId, useRef, useState } from "react"
const sections = ["Objects", "Films", "Notes"]
export default function CurtainIndex({ active = true }: { active?: boolean }) {
const [open, setOpen] = useState(false)
const [selected, setSelected] = useState(0)
const [instant, setInstant] = useState(false)
const toggle = useRef<HTMLButtonElement>(null)
const menu = useRef<HTMLDivElement>(null)
const id = useId()
useEffect(() => {
if (open) menu.current?.querySelector<HTMLButtonElement>("button")?.focus({ preventScroll: true })
}, [open])
function close(index?: number) {
if (index !== undefined) setSelected(index)
setOpen(false)
toggle.current?.focus({ preventScroll: true })
}
return (
<section
className="sf-curtain-index"
data-open={open}
data-still={!active || instant}
onPointerDownCapture={() => setInstant(false)}
onKeyDownCapture={(event) => {
setInstant(true)
if (event.key === "Escape" && open) {
event.preventDefault()
close()
}
}}
aria-label="Editorial section navigation"
>
<div className="ci-current" aria-hidden={open}>
<svg viewBox="0 0 280 200" aria-hidden="true">
<g fill="none" stroke="currentColor" strokeWidth="1.4">
{Array.from({ length: 15 }, (_, i) => (
<ellipse
key={i}
cx="140"
cy="100"
rx={35 + i * 4.6}
ry={16 + i * 4.1}
transform={`rotate(${selected === 0 ? i * 7 : selected === 1 ? 60 - i * 7 : i * 12} 140 100)`}
/>
))}
</g>
</svg>
<span>{sections[selected]}</span>
</div>
<div className="ci-curtain" aria-hidden="true" />
<div ref={menu} className="ci-menu" id={id} inert={!open} aria-hidden={!open} aria-label="Choose section">
{sections.map((name, index) => (
<button
type="button"
key={name}
aria-current={selected === index ? "page" : undefined}
style={{ "--order": index } as CSSProperties}
onClick={() => close(index)}
>
<span>{name}</span>
<i aria-hidden="true">↗</i>
</button>
))}
</div>
<button
ref={toggle}
type="button"
className="ci-toggle"
aria-controls={id}
aria-expanded={open}
onClick={() => (open ? close() : setOpen(true))}
>
<span>{open ? "Close" : "Index"}</span>
<i aria-hidden="true">
<b />
<b />
</i>
</button>
<style>{`
.sf-curtain-index{position:relative;width:100%;height:100%;min-height:280px;background:#f355d9;color:#571a3a;overflow:hidden;isolation:isolate;box-sizing:border-box;font-family:Arial,sans-serif}.sf-curtain-index *{box-sizing:border-box}.sf-curtain-index .ci-current{position:absolute;inset:0;display:grid;place-items:center}.sf-curtain-index .ci-current svg{height:80%;width:65%;max-width:350px;opacity:.75}.sf-curtain-index .ci-current>span{position:absolute;bottom:24px;left:28px;font:italic 54px/.95 Georgia,serif;letter-spacing:-3px}.sf-curtain-index .ci-curtain{position:absolute;inset:0;background:#571a3a;transform:scaleY(0);transform-origin:top;transition:transform .38s cubic-bezier(.76,0,.24,1)}.sf-curtain-index[data-open=true] .ci-curtain{transform:scaleY(1)}.sf-curtain-index button{font:inherit;color:inherit;cursor:pointer;touch-action:manipulation}.sf-curtain-index .ci-toggle{position:absolute;top:12px;right:20px;z-index:3;display:flex;align-items:center;gap:25px;min-height:44px;border:0;background:none;font-size:12px}.sf-curtain-index .ci-toggle i{position:relative;width:26px;height:12px}.sf-curtain-index .ci-toggle b{position:absolute;width:26px;height:1px;background:currentColor;left:0;transition:transform .22s cubic-bezier(.2,.8,.2,1)}.sf-curtain-index .ci-toggle b:first-child{top:3px}.sf-curtain-index .ci-toggle b:last-child{top:9px}.sf-curtain-index[data-open=true] .ci-toggle{color:#ffa9eb}.sf-curtain-index[data-open=true] .ci-toggle b:first-child{transform:translateY(3px) rotate(45deg)}.sf-curtain-index[data-open=true] .ci-toggle b:last-child{transform:translateY(-3px) rotate(-45deg)}.sf-curtain-index .ci-menu{position:absolute;inset:61px 28px 21px;display:flex;flex-direction:column;justify-content:center;visibility:hidden;transition:visibility 0s .38s}.sf-curtain-index[data-open=true] .ci-menu{visibility:visible;transition-delay:0s}.sf-curtain-index .ci-menu button{display:flex;align-items:center;justify-content:space-between;flex:1;max-height:84px;min-height:52px;border:0;border-top:1px solid #e285b646;background:none;text-align:left;padding:1px 0;color:#ffa9eb;opacity:0;transform:translateY(-12px);transition:opacity .12s ease,transform .2s cubic-bezier(.2,.8,.2,1)}.sf-curtain-index .ci-menu button span{font:italic clamp(42px,8vw,65px)/1 Georgia,serif;letter-spacing:-3px}.sf-curtain-index .ci-menu button i{font:normal 28px Arial,sans-serif;opacity:0;transition:opacity .15s ease}.sf-curtain-index .ci-menu button[aria-current=page] i{opacity:1}.sf-curtain-index[data-open=true] .ci-menu button{opacity:1;transform:translateY(0);transition:opacity .18s ease,transform .3s cubic-bezier(.2,.8,.2,1);transition-delay:calc(100ms + var(--order)*35ms)}.sf-curtain-index button:focus-visible{outline:2px solid currentColor;outline-offset:4px}.sf-curtain-index[data-still=true] *{transition:none!important;animation:none!important}@media(hover:hover) and (pointer:fine){.sf-curtain-index .ci-menu button:hover{color:#fff}.sf-curtain-index .ci-menu button:hover i{opacity:1}}@media(prefers-reduced-motion:reduce){.sf-curtain-index *{transition:none!important;animation:none!important}}`}</style>
</section>
)
}
Where it works well
Occasional portfolio or publication navigation with a strong typographic identity.
Reduced motion
The menu opens and closes immediately, including keyboard-triggered changes.
Things to know
Local section selection, not a router. The curtain is contained in its parent and does not act as a page-wide modal.