
Titular gigante
GratisBloques de web · GSAP
La pieza
Una sección FAQ editorial en papel cálido y tinta marina. Cada respuesta se despliega con un muelle integrado en JS y reproducido con la Web Animations API: la altura sobrepasa un poco y se asienta, el + gira hasta ser − con un rebote más vivo y, si interrumpes la animación, sigue desde la velocidad que llevaba. Con ratón, las filas cerradas se estiran unos píxeles al pasar por encima, y un cabo dibujado en SVG vibra como una cuerda cada vez que abres o cierras algo. Incluye buscador insensible a tildes con resaltado, abrir y cerrar todas, navegación con flechas, anclas por pregunta y el marcado JSON-LD FAQPage construido a partir del mismo texto visible. Encaja en webs de servicios, alquileres, clínicas, asesorías o reservas; cambia papel, tinta y acento para adaptarla a tu marca.
Encaja bien en webs de
Antes de copiar
Cambia lo que quieras y copia: el prompt sale ya con el nombre, los colores y los textos de tu cliente. Lo que dejes en blanco se queda como en la demo.
Gratis y entero
Qué trae el prompt, por apartados
Tokens estimados para español (≈3,6 caracteres por token): sirven para orientarte, no son exactos.
# Recrea en un único archivo HTML: FAQ con acordeón de muelle
Una sección de preguntas frecuentes editorial, en papel cálido y tinta marina, cuyas respuestas se abren con un muelle físico real (rebota un poco y se asienta), con buscador sin tildes, resaltado de coincidencias y el JSON-LD `FAQPage` generado a partir del mismo contenido.
Eres un desarrollador front-end creativo senior. Genera un único `index.html` autocontenido que
reproduzca exactamente la pieza descrita: mismo layout, textos, colores, movimiento e interacción.
HTML, CSS y JS en un solo archivo, sin build ni frameworks. Librerías solo por CDN y en las versiones
indicadas. Todos los valores numéricos de este documento son definitivos: úsalos tal cual.
Responde solo con el archivo completo.
## Qué es
Una sección FAQ lista para pegar en la web de un negocio. La marca de ejemplo es **Noray**, un chárter de veleros en el Puerto Deportivo de Brenmar (el juego de palabras es deliberado: *muelle* es a la vez el resorte y el sitio donde amarran los barcos).
Composición en escritorio (≥ 981 px):
- Una **barra de metadatos** fina arriba, en monoespaciada y mayúsculas: marca a la izquierda, coordenadas en el centro, temporada a la derecha, con una línea finísima debajo.
- **Columna izquierda (5/12), pegajosa** (`position: sticky; top: 40px`): antetítulo con un punto de color acento, titular enorme en serif («Antes de soltar *amarras*.», con «amarras» en cursiva y color acento), un **cabo decorativo en SVG** tendido entre dos norays que **vibra como una cuerda pulsada** cada vez que se abre o se cierra una pregunta, un párrafo de entrada, el **buscador** (campo alto con lupa, atajo `/` y botón de borrar) y cinco **sugerencias** en forma de píldora.
- **Columna derecha (7/12)**: cabecera con el estado del buscador («10 preguntas», «1 resultado para «fianza»»…) y el botón «Abrir todas»; una **lista ordenada de 10 preguntas** separadas por filetes, cada una con su número en monoespaciada (01…10), la pregunta en serif grande y un **icono circular + que gira hasta ser −** con un muelle más vivo. Debajo, un bloque de **contacto** («¿No está tu duda?») y una tarjeta plegable de **datos estructurados** que enseña el JSON-LD generado, coloreado, con botón «Copiar».
Detalles que definen la pieza:
1. **Altura animada por un muelle** (rigidez 170, amortiguación 15, masa 1) integrado en JS y reproducido con la Web Animations API como fotogramas clave lineales a 60 fps. El cierre usa un muelle casi crítico (260 / 31) para no rebotar. Si se interrumpe a mitad, arranca desde la altura y la **velocidad** actuales: nunca salta.
2. **Tensión al pasar el ratón**: una fila cerrada se estira 8 px como si alguien tirara del muelle (320 / 11) y vuelve al soltarla; al hacer clic, esa tensión se transfiere a la altura del panel sin salto.
3. La fila abierta pinta su filete superior en color acento (2 px, crece de izquierda a derecha), el número pasa a color acento y el círculo del icono se rellena de tinta.
4. **Buscador** insensible a tildes y mayúsculas («ninos» encuentra «niños»), con varias palabras en modo Y, `<mark>` en las coincidencias de pregunta y respuesta, etiqueta «En la respuesta» cuando la coincidencia solo está en la respuesta, estado vacío con contacto y región `aria-live`.
5. **JSON-LD `FAQPage`** construido en tiempo de ejecución desde el DOM (el mismo texto que ve el usuario) e insertado en el `<head>`.
Es accesible por completo: `button` con `aria-expanded` y `aria-controls`, encabezados `h3`, flechas/Inicio/Fin entre preguntas, foco visible, `prefers-reduced-motion`. Sin JS, todas las respuestas se ven abiertas.
## Librerías y esqueleto
Sin librerías de JS. Solo tres familias de Google Fonts (todas con ñ, tildes, ¿ y ¡):
```html
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Preguntas frecuentes · Noray, chárter de veleros en Brenmar</title>
<meta name="description" content="Preguntas frecuentes sobre el alquiler de veleros con y sin patrón en el Puerto Deportivo de Brenmar.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&family=Instrument+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>/* CSS completo del apartado «Tokens de diseño» */</style>
</head>
<body>
<main><!-- HTML del apartado «Estructura y contenido» --></main>
<script>/* JS completo del apartado «Código clave» */</script>
</body>
</html>
```
- **Newsreader** (serif display con eje óptico `opsz` 6–72): titular, preguntas, cifras, títulos de bloques.
- **Instrument Sans**: texto corrido y botones.
- **IBM Plex Mono**: números 01–10, etiquetas en mayúsculas, estado, código.
El `<script>` va al final del `<body>` (no en `<head>`), sin `defer` ni módulos: tiene que ejecutarse antes del primer pintado para ocultar los paneles sin parpadeo.
## Tokens de diseño
Paleta: papel `#f3eee4` (fondo), papel claro `#fbf8f2` (campo, tarjetas, celdas de datos), tinta `#11242f` (texto, icono abierto, bloque de código), tinta suave `#46545d` (texto secundario, contraste 6,8:1 sobre el papel), acento `#b8431b` (boya: «amarras», números abiertos, filete activo, foco; 4,7:1 sobre el papel). Los filetes y el fondo del resaltado se derivan con `color-mix` de la tinta y el acento, así que cambian solos con la marca. Sobre el fondo hay un grano de papel procedural (SVG `feTurbulence` en `data:` URI, opacidad .55), fijo y sin eventos de puntero.
Este es el CSS **completo y definitivo**. Cópialo literal dentro de `<style>`:
```css
:root{
--papel:#f3eee4;
--papel-2:#fbf8f2;
--tinta:#11242f;
--tinta-2:#46545d;
--linea:color-mix(in srgb,var(--tinta) 16%,transparent);
--linea-2:color-mix(in srgb,var(--tinta) 28%,transparent);
--acento:#b8431b;
--marca:color-mix(in srgb,var(--acento) 16%,transparent);
--codigo-k:#f2b38d;
--codigo-s:#a9d6cc;
--f-display:"Newsreader",Georgia,"Times New Roman",serif;
--f-texto:"Instrument Sans",system-ui,-apple-system,"Segoe UI",sans-serif;
--f-mono:"IBM Plex Mono",ui-monospace,Menlo,Consolas,monospace;
--e-salida:cubic-bezier(.16,1,.3,1);
--e-rebote:cubic-bezier(.34,1.56,.64,1);
--lado:clamp(20px,5vw,72px);
}
*,*::before,*::after{box-sizing:border-box}
html{-webkit-text-size-adjust:100%;text-size-adjust:100%}
body{margin:0;min-height:100svh;background:var(--papel);color:var(--tinta);font:400 16px/1.6 var(--f-texto);-webkit-font-smoothing:antialiased;overflow-x:hidden}
body::before{content:"";position:fixed;inset:0;pointer-events:none;z-index:0;opacity:.55;background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 .07 0 0 0 0 .14 0 0 0 0 .18 0 0 0 .09 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>")}
[hidden]{display:none!important}
a{color:inherit}
button,input{font:inherit;color:inherit}
:focus-visible{outline:2px solid var(--acento);outline-offset:3px;border-radius:6px}
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
.faq{position:relative;z-index:1;max-width:1360px;margin:0 auto;padding:0 var(--lado) clamp(64px,9vw,128px)}
.meta{display:flex;justify-content:space-between;gap:24px;padding:22px 0;border-bottom:1px solid var(--linea);font:500 11.5px/1.4 var(--f-mono);letter-spacing:.08em;text-transform:uppercase;color:var(--tinta-2)}
.meta b{font-weight:500;color:var(--tinta)}
.rejilla{display:grid;grid-template-columns:minmax(0,5fr) minmax(0,7fr);gap:clamp(40px,7vw,120px);padding-top:clamp(48px,7vw,104px)}
/* Columna de introducción */
.intro{position:sticky;top:40px;align-self:start}
.eyebrow{display:flex;align-items:center;gap:10px;margin:0 0 28px;font:500 12px/1 var(--f-mono);letter-spacing:.1em;text-transform:uppercase;color:var(--tinta-2)}
.eyebrow::before{content:"";width:8px;height:8px;border-radius:50%;background:var(--acento)}
h2{margin:0;font:400 clamp(46px,6.1vw,92px)/.94 var(--f-display);letter-spacing:-.025em;font-variation-settings:"opsz" 72}
h2 em{font-style:italic;color:var(--acento)}
.cabo{display:block;width:100%;max-width:440px;height:40px;margin:26px 0 22px;overflow:visible}
.cabo__norai{fill:var(--tinta)}
.cabo__linea{fill:none;stroke:var(--tinta);stroke-width:2.6;stroke-linecap:round}
.cabo__torsion{fill:none;stroke:var(--papel);stroke-width:1.1;stroke-dasharray:1.6 3.4;stroke-linecap:round}
.lead{margin:0 0 36px;max-width:40ch;font-size:clamp(16px,1.25vw,18px);color:var(--tinta-2)}
.buscador label{display:block;margin-bottom:10px;font:500 12px/1 var(--f-mono);letter-spacing:.08em;text-transform:uppercase;color:var(--tinta-2)}
.campo{position:relative;display:flex;align-items:center;height:58px;border:1px solid var(--linea-2);border-radius:14px;background:var(--papel-2);transition:border-color .25s,box-shadow .25s}
.campo:focus-within{border-color:var(--tinta);box-shadow:0 0 0 4px color-mix(in srgb,var(--tinta) 8%,transparent)}
.campo svg{flex:none;width:18px;height:18px;margin:0 12px 0 18px;stroke:var(--tinta);fill:none;stroke-width:1.8}
.campo input{flex:1;min-width:0;height:100%;border:0;background:none;outline:none;font-size:17px;padding:0 8px 0 0}
.campo input::placeholder{color:#6d787f}
.campo input::-webkit-search-cancel-button{display:none}
.campo input:not(:placeholder-shown)~kbd{display:none}
.campo kbd{margin-right:14px;padding:3px 8px;border:1px solid var(--linea-2);border-radius:6px;font:500 12px/1.2 var(--f-mono);color:var(--tinta-2)}
.borrar{display:grid;place-items:center;width:36px;height:36px;margin-right:10px;border:0;border-radius:50%;background:var(--tinta);color:var(--papel);cursor:pointer}
.borrar svg{margin:0;width:12px;height:12px;stroke:currentColor;stroke-width:2.2}
.sugerencias{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-top:14px;font-size:14px;color:var(--tinta-2)}
.sugerencias span{margin-right:4px}
.chip{padding:6px 13px;border:1px solid var(--linea-2);border-radius:999px;background:none;font-size:14px;cursor:pointer;transition:background .3s,color .3s,border-color .3s,transform .5s var(--e-rebote)}
.chip:active{transform:scale(.94)}
.contacto{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:20px 40px;align-items:start;margin-top:48px;padding:28px 0 0 64px}
.contacto h3{margin:0 0 8px;font:400 26px/1.2 var(--f-display)}
.contacto p{margin:0;max-width:40ch;color:var(--tinta-2)}
.enlaces{display:flex;flex-direction:column;align-items:flex-start;gap:12px}
.enlaces a{display:inline-flex;align-items:center;gap:8px;font-weight:500;text-decoration:none;border-bottom:1px solid var(--linea-2);padding-bottom:3px;transition:border-color .3s}
.enlaces a span{display:inline-block;transition:transform .5s var(--e-rebote)}
.contacto .horario{margin-top:6px;font:500 11px/1.6 var(--f-mono);letter-spacing:.06em;text-transform:uppercase;color:var(--tinta-2)}
/* Lista */
.cabecera{display:flex;justify-content:space-between;align-items:center;gap:16px;padding-bottom:18px;font:500 12px/1 var(--f-mono);letter-spacing:.08em;text-transform:uppercase;color:var(--tinta-2)}
.estado{margin:0}
.todas{display:inline-flex;align-items:center;gap:8px;padding:8px 0;border:0;background:none;font:inherit;letter-spacing:inherit;text-transform:inherit;color:var(--tinta);cursor:pointer}
.todas::after{content:"";width:18px;height:1px;background:currentColor;transition:width .5s var(--e-rebote)}
.acordeon{list-style:none;margin:0;padding:0;border-bottom:1px solid var(--linea)}
.item{position:relative;border-top:1px solid var(--linea)}
.item::before{content:"";position:absolute;left:0;right:0;top:-1px;height:2px;background:var(--acento);transform:scaleX(0);transform-origin:left;transition:transform .8s var(--e-salida)}
.item.abierto::before{transform:scaleX(1)}
.item h3{margin:0;font:inherit}
.boton{display:grid;grid-template-columns:52px minmax(0,1fr) auto;align-items:center;gap:0 12px;width:100%;padding:26px 0;border:0;background:none;text-align:left;cursor:pointer;border-radius:0}
.num{align-self:start;padding-top:.55em;font:500 12px/1 var(--f-mono);letter-spacing:.08em;color:var(--tinta-2);transition:color .4s}
.abierto .num{color:var(--acento)}
.pregunta{display:block;font:400 clamp(21px,1.95vw,28px)/1.22 var(--f-display);letter-spacing:-.01em;transition:transform .6s var(--e-rebote)}
.etiqueta{display:inline-block;margin-top:8px;padding:3px 9px;border-radius:999px;background:var(--marca);font:500 11px/1.3 var(--f-mono);letter-spacing:.06em;text-transform:uppercase;color:var(--acento)}
.icono{position:relative;display:grid;place-items:center;width:44px;height:44px;border:1px solid var(--linea-2);border-radius:50%;transition:background .35s,border-color .35s,transform .6s var(--e-rebote)}
.icono i{position:absolute;width:15px;height:1.6px;border-radius:2px;background:var(--tinta);transition:background .35s}
.icono i+i{transform:rotate(90deg)}
.abierto .icono{background:var(--tinta);border-color:var(--tinta)}
.abierto .icono i{background:var(--papel)}
.panel{overflow:hidden}
.tension{height:0}
.cuerpo{padding:0 56px 32px 64px;color:var(--tinta-2);font-size:16.5px}
.cuerpo p{margin:0 0 12px;max-width:60ch}
.cuerpo p:last-child{margin-bottom:0}
.cuerpo strong{font-weight:600;color:var(--tinta)}
.cuerpo a{color:var(--tinta);text-underline-offset:3px}
mark{background:var(--marca);color:var(--tinta);border-radius:3px;padding:0 1px;box-shadow:0 1.5px 0 var(--acento)}
.datos{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1px;max-width:560px;margin:18px 0 4px;border:1px solid var(--linea);border-radius:12px;overflow:hidden;background:var(--linea)}
.datos div{padding:14px 16px;background:var(--papel-2)}
.datos dt{font:500 11px/1.3 var(--f-mono);letter-spacing:.06em;text-transform:uppercase;color:var(--tinta-2)}
.datos dd{margin:6px 0 0;font:400 22px/1.1 var(--f-display);color:var(--tinta)}
.datos dd small{display:block;margin-top:4px;font:400 12.5px/1.3 var(--f-texto);color:var(--tinta-2)}
.vacio{padding:34px 0 30px 64px;border-top:1px solid var(--linea)}
.vacio p{margin:0 0 14px;font:400 26px/1.25 var(--f-display)}
.vacio p+p{font:400 16px/1.6 var(--f-texto);color:var(--tinta-2)}
.vacio button{padding:10px 18px;border:1px solid var(--tinta);border-radius:999px;background:none;cursor:pointer;font-weight:500}
/* JSON-LD */
.ld{margin-top:40px;border:1px solid var(--linea);border-radius:16px;background:var(--papel-2)}
.ld h3{margin:0;font:inherit}
.ld .boton{grid-template-columns:minmax(0,1fr) auto;padding:18px 18px 18px 22px}
.ld__tit{display:block;font:500 12px/1 var(--f-mono);letter-spacing:.08em;text-transform:uppercase;color:var(--tinta-2)}
.ld__sub{display:block;margin-top:8px;font:400 19px/1.25 var(--f-display)}
.ld .icono{width:38px;height:38px}
.ld .cuerpo{padding:0 18px 18px}
.codigo{position:relative;border-radius:12px;background:var(--tinta);color:#e9e3d6}
.codigo pre{margin:0;max-height:340px;overflow:auto;padding:22px 20px;font:400 12.5px/1.65 var(--f-mono);white-space:pre-wrap;word-break:break-word;border-radius:12px}
.codigo .k{color:var(--codigo-k)}
.codigo .s{color:var(--codigo-s)}
.copiar{position:absolute;top:10px;right:10px;padding:7px 12px;border:1px solid rgba(233,227,214,.35);border-radius:999px;background:var(--tinta);color:#e9e3d6;font:500 11px/1 var(--f-mono);letter-spacing:.06em;text-transform:uppercase;cursor:pointer}
.nota{margin:12px 2px 0;font-size:14px;color:var(--tinta-2)}
@media (hover:hover) and (pointer:fine){
.boton:hover .pregunta{transform:translateX(8px)}
.item .boton:hover .icono{transform:scale(1.08)}
.item:not(.abierto) .boton:hover .icono{border-color:var(--tinta)}
.chip:hover{background:var(--tinta);border-color:var(--tinta);color:var(--papel)}
.enlaces a:hover{border-color:var(--tinta)}
.enlaces a:hover span{transform:translateX(4px)}
.todas:hover::after{width:30px}
}
@media (hover:none){.campo kbd{display:none}}
@media (max-width:980px){
.rejilla{grid-template-columns:minmax(0,1fr);gap:56px}
.intro{position:static}
.meta .coord{display:none}
}
@media (max-width:600px){
.meta{padding:18px 0;font-size:10.5px}
.meta .meses,.campo kbd{display:none}
.contacto{grid-template-columns:minmax(0,1fr);padding-left:0;margin-top:40px}
.vacio{padding-left:0}
.rejilla{padding-top:40px;gap:48px}
h2{font-size:clamp(42px,12.5vw,60px)}
.cabo{margin:20px 0 18px}
.lead{margin-bottom:28px}
.boton{grid-template-columns:34px minmax(0,1fr) auto;padding:22px 0;gap:0 10px}
.num{font-size:11px}
.icono{width:40px;height:40px}
.cuerpo{padding:0 0 26px 44px;font-size:16px}
.datos{grid-template-columns:minmax(0,1fr)}
.datos div{display:flex;justify-content:space-between;align-items:baseline;gap:12px}
.datos dd{margin:0;text-align:right}
.ld .cuerpo{padding:0 10px 10px}
}
@media (prefers-reduced-motion:reduce){
*,*::before,*::after{transition-duration:.01ms!important;animation-duration:.01ms!important}
.boton:hover .pregunta{transform:none}
}
```
Medidas clave, por si hay que razonar sobre ellas:
| Elemento | Valor |
|---|---|
| Contenedor | `max-width: 1360px`, lateral `clamp(20px, 5vw, 72px)`, abajo `clamp(64px, 9vw, 128px)` |
| Rejilla | `5fr / 7fr`, hueco `clamp(40px, 7vw, 120px)`, arriba `clamp(48px, 7vw, 104px)` |
| Titular | Newsreader 400, `clamp(46px, 6.1vw, 92px)`, interlineado .94, tracking −.025em, `opsz` 72 |
| Pregunta | Newsreader 400, `clamp(21px, 1.95vw, 28px)`, interlineado 1.22 |
| Fila | rejilla `52px · 1fr · auto`, hueco 12 px, padding vertical 26 px |
| Icono | círculo 44 px, borde 1 px `--linea-2`; barras 15 × 1,6 px |
| Respuesta | 16,5 px, padding `0 56px 32px 64px`, párrafos a 60ch |
| Campo de búsqueda | alto 58 px, radio 14 px, texto 17 px |
| Tarjeta JSON-LD | radio 16 px; código Plex Mono 12,5/1,65 sobre tinta, `max-height: 340px` |
## Estructura y contenido
Árbol de la sección (todo el texto es literal; respeta los `id`, clases y atributos ARIA porque el JS depende de ellos):
- `section.faq[aria-labelledby=faq-titulo]`
- `div.meta`: «**Noray** — Chárter de veleros» · coordenadas «36°30′31″N · 4°53′07″O» (`aria-hidden`, clase `coord`, se oculta ≤ 980 px) · «Temporada 2026 · abril–octubre» (el trozo « · abril–octubre» en `span.meses`, se oculta ≤ 600 px).
- `div.rejilla`
- `div.intro`: antetítulo, `h2#faq-titulo`, `svg.cabo`, `p.lead`, `div.buscador[role=search]`.
- `div.lista`: `div.cabecera` (estado + «Abrir todas»), `ol.acordeon` con 10 `li.item[data-item]`, `div.vacio[hidden]`, `aside.contacto`, `div.ld`.
Cada pregunta sigue exactamente este patrón (el `id` del `li` sirve de ancla `#fianza`, etc.):
```html
<li class="item" id="SLUG" data-item>
<h3><button type="button" class="boton" id="q-SLUG" aria-expanded="false" aria-controls="r-SLUG"><span class="num" aria-hidden="true">NN</span><span><span class="pregunta">PREGUNTA</span><span class="etiqueta" hidden>En la respuesta</span></span><span class="icono" aria-hidden="true"><i></i><i></i></span></button></h3>
<div class="panel" id="r-SLUG"><div class="cuerpo">
<p>RESPUESTA</p>
</div></div>
</li>
```
Los paneles **no** llevan `hidden` en el HTML (sin JS se ven todos); el JS los oculta al arrancar. Tampoco llevan `role="region"`: con 10 paneles sería ruido para el lector de pantalla.
HTML completo y definitivo del `<main>`:
```html
<main>
<section class="faq" aria-labelledby="faq-titulo">
<div class="meta">
<span><b>Noray</b> — Chárter de veleros</span>
<span class="coord" aria-hidden="true">36°30′31″N · 4°53′07″O</span>
<span>Temporada 2026<span class="meses"> · abril–octubre</span></span>
</div>
<div class="rejilla">
<div class="intro">
<p class="eyebrow">Preguntas frecuentes · Brenmar</p>
<h2 id="faq-titulo">Antes de soltar <em>amarras</em>.</h2>
<svg class="cabo" viewBox="0 0 440 40" aria-hidden="true" focusable="false">
<path class="cabo__linea" d="M16 20 L424 20"/>
<path class="cabo__torsion" d="M16 20 L424 20"/>
<g class="cabo__norai"><rect x="4" y="12" width="10" height="18" rx="2"/><rect x="1" y="9" width="16" height="5" rx="2.5"/><rect x="426" y="12" width="10" height="18" rx="2"/><rect x="423" y="9" width="16" height="5" rx="2.5"/></g>
</svg>
<p class="lead">Todo lo que nos preguntan en el pantalán, resuelto antes de que embarques. Y si falta algo, el patrón está al otro lado del teléfono.</p>
<div class="buscador" role="search">
<label for="faq-q">Buscar en las preguntas</label>
<div class="campo">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m15.5 15.5 5 5" stroke-linecap="round"/></svg>
<input id="faq-q" type="search" placeholder="Fianza, patrón, perro…" autocomplete="off" spellcheck="false" aria-describedby="faq-estado">
<kbd aria-hidden="true">/</kbd>
<button type="button" class="borrar" aria-label="Borrar la búsqueda" hidden><svg viewBox="0 0 12 12" aria-hidden="true"><path d="M2 2l8 8M10 2l-8 8" stroke-linecap="round"/></svg></button>
</div>
<div class="sugerencias"><span>Prueba con</span>
<button type="button" class="chip" data-q="fianza">fianza</button>
<button type="button" class="chip" data-q="patrón">patrón</button>
<button type="button" class="chip" data-q="perro">perro</button>
<button type="button" class="chip" data-q="cancelar">cancelar</button>
<button type="button" class="chip" data-q="niños">niños</button>
</div>
</div>
</div>
<div class="lista">
<div class="cabecera">
<p class="estado" id="faq-estado" aria-live="polite">10 preguntas</p>
<button type="button" class="todas">Abrir todas</button>
</div>
<ol class="acordeon">
<li class="item" id="titulacion" data-item>
<h3><button type="button" class="boton" id="q-titulacion" aria-expanded="false" aria-controls="r-titulacion"><span class="num" aria-hidden="true">01</span><span><span class="pregunta">¿Necesito titulación para alquilar un velero?</span><span class="etiqueta" hidden>En la respuesta</span></span><span class="icono" aria-hidden="true"><i></i><i></i></span></button></h3>
<div class="panel" id="r-titulacion"><div class="cuerpo">
<p>No, si sales con patrón: todos nuestros veleros pueden salir con un <strong>patrón profesional</strong> incluido, que se encarga de la navegación y de elegir la mejor cala según el viento.</p>
<p>Para alquilar sin patrón necesitas como mínimo el <strong>PER</strong> (Patrón de Embarcaciones de Recreo) o una titulación equivalente de tu país, y te pediremos un breve currículum náutico.</p>
</div></div>
</li>
<li class="item" id="precio" data-item>
<h3><button type="button" class="boton" id="q-precio" aria-expanded="false" aria-controls="r-precio"><span class="num" aria-hidden="true">02</span><span><span class="pregunta">¿Qué incluye el precio?</span><span class="etiqueta" hidden>En la respuesta</span></span><span class="icono" aria-hidden="true"><i></i><i></i></span></button></h3>
<div class="panel" id="r-precio"><div class="cuerpo">
<p>El barco con patrón, el seguro a todo riesgo, el combustible de las dos primeras horas de motor, la limpieza final, toallas, chalecos y equipo de snorkel. Solo pagas aparte lo que quieras añadir, como el catering.</p>
<dl class="datos">
<div><dt>Medio día</dt><dd>390 €<small>4 h · IVA incl.</small></dd></div>
<div><dt>Día completo</dt><dd>690 €<small>8 h · IVA incl.</small></dd></div>
<div><dt>Puesta de sol</dt><dd>290 €<small>2,5 h · IVA incl.</small></dd></div>
</dl>
</div></div>
</li>
<li class="item" id="fianza" data-item>
<h3><button type="button" class="boton" id="q-fianza" aria-expanded="false" aria-controls="r-fianza"><span class="num" aria-hidden="true">03</span><span><span class="pregunta">¿Cuánto es la fianza y cuándo la recupero?</span><span class="etiqueta" hidden>En la respuesta</span></span><span class="icono" aria-hidden="true"><i></i><i></i></span></button></h3>
<div class="panel" id="r-fianza"><div class="cuerpo">
<p>La fianza es de <strong>1.500 €</strong> en veleros de hasta 12 metros y de <strong>2.500 €</strong> en los de 12 a 15 metros. Solo se aplica si alquilas sin patrón.</p>
<p>Se bloquea en tu tarjeta al embarcar (es una preautorización, no un cargo) y se libera al desembarcar si el barco vuelve como salió. Con la reducción de fianza, por 60 € al día IVA incl., se queda en 300 €.</p>
</div></div>
</li>
<li class="item" id="tiempo" data-item>
<h3><button type="button" class="boton" id="q-tiempo" aria-expanded="false" aria-controls="r-tiempo"><span class="num" aria-hidden="true">04</span><span><span class="pregunta">¿Qué pasa si hace mal tiempo?</span><span class="etiqueta" hidden>En la respuesta</span></span><span class="icono" aria-hidden="true"><i></i><i></i></span></button></h3>
<div class="panel" id="r-tiempo"><div class="cuerpo">
<p>El patrón decide la misma mañana con el parte de AEMET. Si no se puede salir por viento o mar (a partir de fuerza 6 de levante o poniente), <strong>te cambiamos la fecha sin coste o te devolvemos el 100 %</strong>, lo que prefieras.</p>
</div></div>
</li>
<li class="item" id="cancelacion" data-item>
<h3><button type="button" class="boton" id="q-cancelacion" aria-expanded="false" aria-controls="r-cancelacion"><span class="num" aria-hidden="true">05</span><span><span class="pregunta">¿Puedo cancelar la reserva?</span><span class="etiqueta" hidden>En la respuesta</span></span><span class="icono" aria-hidden="true"><i></i><i></i></span></button></h3>
<div class="panel" id="r-cancelacion"><div class="cuerpo">
<p>Sí. Hasta 15 días antes te devolvemos el importe íntegro; entre 14 y 7 días, el 50 %. Con menos de 7 días no hay reembolso, pero puedes mover la fecha una vez dentro de la misma temporada.</p>
</div></div>
</li>
<li class="item" id="embarque" data-item>
<h3><button type="button" class="boton" id="q-embarque" aria-expanded="false" aria-controls="r-embarque"><span class="num" aria-hidden="true">06</span><span><span class="pregunta">¿Dónde embarcamos y dónde aparco?</span><span class="etiqueta" hidden>En la respuesta</span></span><span class="icono" aria-hidden="true"><i></i><i></i></span></button></h3>
<div class="panel" id="r-embarque"><div class="cuerpo">
<p>En el <strong>pantalán C del Puerto Deportivo de Brenmar</strong>. Te esperamos 15 minutos antes de la salida con el barco listo. El aparcamiento público del paseo marítimo está a tres minutos a pie y te sellamos el tique.</p>
</div></div>
</li>
<li class="item" id="a-bordo" data-item>
<h3><button type="button" class="boton" id="q-a-bordo" aria-expanded="false" aria-controls="r-a-bordo"><span class="num" aria-hidden="true">07</span><span><span class="pregunta">¿Podemos llevar comida, bebida y a nuestro perro?</span><span class="etiqueta" hidden>En la respuesta</span></span><span class="icono" aria-hidden="true"><i></i><i></i></span></button></h3>
<div class="panel" id="r-a-bordo"><div class="cuerpo">
<p>Sí a todo, con cabeza. A bordo hay nevera, hielo y vasos; solo te pedimos que no subas vidrio. Los perros son bienvenidos con su chaleco: si no tenéis, os prestamos uno (tallas S a L).</p>
</div></div>
</li>
<li class="item" id="plazas" data-item>
<h3><button type="button" class="boton" id="q-plazas" aria-expanded="false" aria-controls="r-plazas"><span class="num" aria-hidden="true">08</span><span><span class="pregunta">¿Cuántas personas caben en cada barco?</span><span class="etiqueta" hidden>En la respuesta</span></span><span class="icono" aria-hidden="true"><i></i><i></i></span></button></h3>
<div class="panel" id="r-plazas"><div class="cuerpo">
<p>Es el máximo que marca el certificado de navegabilidad, así que no se negocia. El patrón no cuenta.</p>
<dl class="datos">
<div><dt>Alcaraván</dt><dd>6<small>9,9 m de eslora</small></dd></div>
<div><dt>Vendaval</dt><dd>8<small>11,5 m de eslora</small></dd></div>
<div><dt>Marejada</dt><dd>11<small>14 m de eslora</small></dd></div>
</dl>
</div></div>
</li>
<li class="item" id="ninos" data-item>
<h3><button type="button" class="boton" id="q-ninos" aria-expanded="false" aria-controls="r-ninos"><span class="num" aria-hidden="true">09</span><span><span class="pregunta">¿Es buena idea con niños o si me mareo?</span><span class="etiqueta" hidden>En la respuesta</span></span><span class="icono" aria-hidden="true"><i></i><i></i></span></button></h3>
<div class="panel" id="r-ninos"><div class="cuerpo">
<p>Sí. Llevamos chalecos infantiles desde 10 kg y una red en los guardamancebos. Si te mareas, reserva la salida de la mañana: el mar suele estar más plano antes de que entre el viento de la tarde.</p>
</div></div>
</li>
<li class="item" id="pago" data-item>
<h3><button type="button" class="boton" id="q-pago" aria-expanded="false" aria-controls="r-pago"><span class="num" aria-hidden="true">10</span><span><span class="pregunta">¿Cómo se paga? ¿Hacéis factura a empresas?</span><span class="etiqueta" hidden>En la respuesta</span></span><span class="icono" aria-hidden="true"><i></i><i></i></span></button></h3>
<div class="panel" id="r-pago"><div class="cuerpo">
<p>Pagas un 30 % al reservar y el resto hasta 48 horas antes. Aceptamos tarjeta, Bizum y transferencia, y emitimos factura completa a nombre de empresa para eventos e incentivos.</p>
</div></div>
</li>
</ol>
<div class="vacio" hidden>
<p>Nada sobre «<span class="vacio__q"></span>».</p>
<p>Pregúntanoslo directamente en <a href="mailto:hola@noray.com">hola@noray.com</a> o al <a href="tel:+00 000 000 000">+00 000 000 000</a> y te contestamos en el día.</p>
<button type="button" class="ver-todas">Ver todas las preguntas</button>
</div>
<aside class="contacto" aria-labelledby="contacto-tit">
<div>
<h3 id="contacto-tit">¿No está tu duda?</h3>
<p>Escríbenos o llama: te responde alguien que sale a navegar cada día, no un formulario.</p>
</div>
<div class="enlaces">
<a href="tel:+00 000 000 000">+00 000 000 000 <span aria-hidden="true">→</span></a>
<a href="mailto:hola@noray.com">hola@noray.com <span aria-hidden="true">→</span></a>
<p class="horario">Puerto Deportivo de Brenmar<br>Pantalán C · 9:00–21:00</p>
</div>
</aside>
<div class="ld">
<h3><button type="button" class="boton" id="q-ld" aria-expanded="false" aria-controls="r-ld"><span><span class="ld__tit">Datos estructurados · FAQPage</span><span class="ld__sub">JSON-LD generado a partir de estas mismas preguntas</span></span><span class="icono" aria-hidden="true"><i></i><i></i></span></button></h3>
<div class="panel" id="r-ld"><div class="cuerpo">
<div class="codigo"><pre tabindex="0" aria-label="Código JSON-LD"><code></code></pre><button type="button" class="copiar">Copiar</button></div>
<p class="nota">Se inserta en el <head> al cargar: si cambias una respuesta, el marcado para buscadores cambia con ella.</p>
</div></div>
</div>
</div>
</div>
</section>
</main>
```
Notas de contenido:
- En los `href="tel:…"` usa el teléfono +00 000 000 000 **sin espacios** (p. ej. `tel:+000000000000`).
- Los precios llevan siempre «IVA incl.».
- Las dos tablas `dl.datos` (precios y plazas por barco) son tres celdas separadas por 1 px de filete (rejilla con `gap: 1px` sobre fondo `--linea`).
## Movimiento
| Elemento | Propiedad | De → a | Duración | Easing | Retardo | Disparador |
|---|---|---|---|---|---|---|
| `.intro > *`, `.cabecera`, cada `li`, `.ld` | opacity, translateY | 0, 18px → 1, 0 | 900 ms | `cubic-bezier(.16,1,.3,1)` | 80 ms + 45 ms × índice | carga |
| Primera pregunta (o la del `#ancla`) | se abre | cerrada → abierta | muelle | — | 750 ms | carga |
| `.panel` al abrir | height | actual → alto de `.cuerpo` | lo que dure el muelle (≈ 0,8 s) | muelle k 170 · c 15 (fotogramas lineales) | 0 | clic / Enter / Espacio |
| `.cuerpo` al abrir | opacity, translateY | 0, −10px → 1, 0 | 420 ms | `cubic-bezier(.2,.8,.2,1)` | 60 ms | con la apertura |
| `.panel` al cerrar | height | actual → 0 | ≈ 0,4 s | muelle k 260 · c 31 | 0 | clic |
| `.cuerpo` al cerrar | opacity | 1 → 0 (fill forwards) | 200 ms | `ease-out` | 0 | con el cierre |
| Segunda barra del icono | rotate | 90° (+) → 180° (−) y vuelta | ≈ 0,7 s | muelle k 230 · c 13 | 0 | con abrir/cerrar |
| Fila cerrada (`div.tension` bajo el `h3`) | height | 0 → 8px → 0 | ≈ 0,5 s | muelle k 320 · c 11 | 0 | `pointerenter`/`pointerleave` con ratón |
| `.item::before` (filete superior) | scaleX | 0 → 1 (origen izquierda) | 800 ms | `--e-salida` | 0 | clase `.abierto` |
| `.num`, fondo de `.icono`, barras | color / fondo | tinta suave → acento; transparente → tinta | 400 / 350 ms | lineal CSS | 0 | clase `.abierto` |
| Cabo SVG | desplazamiento vertical | amplitud A·e^(−3,2 t)·cos(2π·3,4·t) | 2,2 s | — | 0 | cada abrir (fuerza 1) o cerrar (fuerza 0,6) |
| `.pregunta` (hover) | translateX | 0 → 8px | 600 ms | `--e-rebote` | 0 | hover con puntero fino |
| `.icono` (hover) | scale | 1 → 1.08 | 600 ms | `--e-rebote` | 0 | hover con puntero fino |
| Fila que reaparece al filtrar | opacity, translateY | 0, 8px → 1, 0 | 320 ms | `cubic-bezier(.16,1,.3,1)` | 0 | búsqueda |
| «Abrir todas» | cascada | — | — | — | 45 ms × índice | clic |
| Línea tras «Abrir todas» | width | 18px → 30px | 500 ms | `--e-rebote` | 0 | hover |
## Código clave
Este es el JS **completo y definitivo**. Cópialo literal dentro del `<script>` final:
```js
(() => {
const reducido = matchMedia("(prefers-reduced-motion: reduce)");
const FPS = 60;
// Muelles: rigidez k, amortiguación c, masa 1
const MUELLE = {
abrir: { k: 170, c: 15 },
cerrar: { k: 260, c: 31 },
icono: { k: 230, c: 13 },
tension: { k: 320, c: 11 }
};
// Integra un muelle amortiguado y devuelve posiciones y velocidades a 60 fps
function muelle(desde, hasta, v0, { k, c }) {
const h = 1 / (FPS * 4);
const eps = Math.max(Math.abs(hasta - desde), 1) * 0.0025;
let x = desde, v = v0;
const pos = [x], vel = [v];
for (let n = 0; n < FPS * 3; n++) {
for (let s = 0; s < 4; s++) { const a = -k * (x - hasta) - c * v; v += a * h; x += v * h; }
pos.push(x); vel.push(v);
if (Math.abs(x - hasta) < eps && Math.abs(v) < eps * 6) break;
}
pos[pos.length - 1] = hasta; vel[vel.length - 1] = 0;
return { pos, vel, dur: (pos.length - 1) * 1000 / FPS };
}
// Un valor animado con WAAPI que se puede interrumpir conservando la velocidad
class Resorte {
constructor(el, pinta, valor) { this.el = el; this.pinta = pinta; this.valor = valor; this.anim = null; this.s = null; }
actual() {
if (!this.anim) return { x: this.valor, v: 0 };
const s = this.s, ult = s.pos.length - 1;
const f = Math.min((this.anim.currentTime || 0) / (1000 / FPS), ult);
const i = Math.floor(f), j = Math.min(i + 1, ult);
return { x: s.pos[i] + (s.pos[j] - s.pos[i]) * (f - i), v: s.vel[i] };
}
ir(hasta, cfg, fin) {
const { x, v } = this.actual();
if (this.anim) { this.anim.onfinish = null; this.anim.cancel(); this.anim = null; }
this.valor = hasta;
Object.assign(this.el.style, this.pinta(hasta));
if (reducido.matches || (Math.abs(x - hasta) < 0.5 && Math.abs(v) < 1)) { if (fin) fin(); return; }
this.s = muelle(x, hasta, v, cfg);
const a = this.el.animate(this.s.pos.map(this.pinta), { duration: this.s.dur, easing: "linear" });
this.anim = a;
a.onfinish = () => { if (this.anim === a) this.anim = null; if (fin) fin(); };
}
fijar(v) {
if (this.anim) { this.anim.onfinish = null; this.anim.cancel(); this.anim = null; }
this.valor = v; Object.assign(this.el.style, this.pinta(v));
}
}
const pintaAlto = (h) => ({ height: Math.max(0, h).toFixed(2) + "px" });
const pintaGiro = (g) => ({ transform: "rotate(" + g.toFixed(2) + "deg)" });
// Cabo decorativo: vibra como una cuerda pulsada cada vez que algo se abre o cierra
const caboLineas = document.querySelectorAll(".cabo__linea, .cabo__torsion");
let amp = 0, t0 = 0, raf = 0;
function dibujarCabo(a) {
let d = "";
for (let i = 0; i <= 32; i++) {
const u = i / 32, x = 16 + u * 408;
const y = 20 + a * Math.sin(Math.PI * u) + a * 0.28 * Math.sin(2 * Math.PI * u);
d += (i ? " L" : "M") + x.toFixed(1) + " " + y.toFixed(2);
}
caboLineas.forEach((p) => p.setAttribute("d", d));
}
function vibrar(ahora) {
const t = (ahora - t0) / 1000;
dibujarCabo(amp * Math.exp(-t * 3.2) * Math.cos(t * Math.PI * 2 * 3.4));
if (t < 2.2) raf = requestAnimationFrame(vibrar);
else { raf = 0; amp = 0; dibujarCabo(0); }
}
function pulsar(fuerza) {
if (reducido.matches) return;
amp = Math.min(10, amp * 0.5 + 7 * fuerza);
t0 = performance.now();
if (!raf) raf = requestAnimationFrame(vibrar);
}
// Plegables: preguntas y bloque de JSON-LD
function plegable(raiz, btn) {
const panel = document.getElementById(btn.getAttribute("aria-controls"));
const cuerpo = panel.querySelector(".cuerpo");
const barra = btn.querySelector(".icono i + i");
return { raiz, btn, panel, cuerpo, abierto: false, fundido: null,
alto: new Resorte(panel, pintaAlto, 0), cruz: new Resorte(barra, pintaGiro, 90) };
}
function alternar(it, abrir) {
if (it.abierto === abrir) return;
it.abierto = abrir;
it.btn.setAttribute("aria-expanded", String(abrir));
it.raiz.classList.toggle("abierto", abrir);
const p = it.panel;
if (it.fundido) { it.fundido.cancel(); it.fundido = null; }
if (abrir) {
if (!it.alto.anim) it.alto.valor = it.tension ? it.tension.actual().x : 0;
if (it.tension) it.tension.fijar(0);
p.hidden = false;
it.alto.ir(it.cuerpo.offsetHeight, MUELLE.abrir, () => { p.style.height = ""; });
if (!reducido.matches) it.cuerpo.animate(
[{ opacity: 0, transform: "translateY(-10px)" }, { opacity: 1, transform: "none" }],
{ duration: 420, delay: 60, easing: "cubic-bezier(.2,.8,.2,1)", fill: "backwards" });
} else {
if (!it.alto.anim) it.alto.valor = p.offsetHeight;
it.alto.ir(0, MUELLE.cerrar, () => { p.hidden = true; p.style.height = ""; });
if (!reducido.matches) it.fundido = it.cuerpo.animate([{ opacity: 1 }, { opacity: 0 }],
{ duration: 200, easing: "ease-out", fill: "forwards" });
}
it.cruz.ir(abrir ? 180 : 90, MUELLE.icono);
pulsar(abrir ? 1 : 0.6);
actualizarTodas();
}
const lista = document.querySelector(".acordeon");
const items = [...lista.querySelectorAll("[data-item]")].map((li) => {
const it = plegable(li, li.querySelector(".boton"));
it.q = li.querySelector(".pregunta");
it.tag = li.querySelector(".etiqueta");
it.htmlQ = it.q.innerHTML;
it.htmlA = it.cuerpo.innerHTML;
it.textoQ = it.q.textContent.trim();
it.textoA = it.cuerpo.textContent.replace(/\s+/g, " ").trim();
// Tensión: con ratón, la fila cerrada se estira unos píxeles como un muelle al pasar por encima
const t = document.createElement("div");
t.className = "tension";
t.setAttribute("aria-hidden", "true");
li.querySelector("h3").after(t);
it.tension = new Resorte(t, pintaAlto, 0);
it.btn.addEventListener("pointerenter", (e) => { if (e.pointerType === "mouse" && !it.abierto) it.tension.ir(8, MUELLE.tension); });
it.btn.addEventListener("pointerleave", () => { if (!it.abierto) it.tension.ir(0, MUELLE.tension); });
return it;
});
const ld = plegable(document.querySelector(".ld"), document.getElementById("q-ld"));
[...items, ld].forEach((it) => {
it.panel.hidden = true;
it.btn.addEventListener("click", () => alternar(it, !it.abierto));
});
// Abrir o cerrar todas las visibles, en cascada
const btnTodas = document.querySelector(".todas");
const visibles = () => items.filter((it) => !it.raiz.hidden);
function actualizarTodas() {
const v = visibles();
btnTodas.textContent = v.length && v.every((it) => it.abierto) ? "Cerrar todas" : "Abrir todas";
btnTodas.hidden = !v.length;
}
btnTodas.addEventListener("click", () => {
const v = visibles(), abrir = !v.every((it) => it.abierto);
v.forEach((it, i) => setTimeout(() => alternar(it, abrir), reducido.matches ? 0 : i * 45));
});
// Teclado: flechas, Inicio y Fin entre preguntas
lista.addEventListener("keydown", (e) => {
const bts = visibles().map((it) => it.btn), i = bts.indexOf(document.activeElement);
if (i < 0) return;
const destino = { ArrowDown: bts[(i + 1) % bts.length], ArrowUp: bts[(i - 1 + bts.length) % bts.length], Home: bts[0], End: bts[bts.length - 1] }[e.key];
if (destino) { e.preventDefault(); destino.focus(); }
});
// Buscador sin tildes ni mayúsculas, con resaltado
const quitar = (s) => s.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
const normaliza = (s) => s.split("").map((ch) => quitar(ch).charAt(0) || ch).join("");
items.forEach((it) => { it.nq = normaliza(it.textoQ); it.na = normaliza(it.textoA); });
function marcar(el, terms) {
const nodos = [], w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
while (w.nextNode()) nodos.push(w.currentNode);
nodos.forEach((nodo) => {
const t = nodo.nodeValue, n = normaliza(t), tramos = [];
terms.forEach((term) => { let i = n.indexOf(term); while (i > -1) { tramos.push([i, i + term.length]); i = n.indexOf(term, i + term.length); } });
if (!tramos.length) return;
tramos.sort((a, b) => a[0] - b[0]);
const frag = document.createDocumentFragment();
let cursor = 0;
tramos.forEach(([a, b]) => {
if (a < cursor) a = cursor;
if (b <= a) return;
frag.append(t.slice(cursor, a));
const m = document.createElement("mark"); m.textContent = t.slice(a, b); frag.append(m);
cursor = b;
});
frag.append(t.slice(cursor));
nodo.replaceWith(frag);
});
}
const input = document.getElementById("faq-q");
const borrar = document.querySelector(".borrar");
const estado = document.getElementById("faq-estado");
const vacio = document.querySelector(".vacio");
let espera = 0;
function filtrar() {
const bruto = input.value.trim();
const terms = normaliza(bruto.toLowerCase()).split(/\s+/).filter(Boolean);
borrar.hidden = !input.value;
let n = 0;
items.forEach((it) => {
const antes = it.raiz.hidden;
it.q.innerHTML = it.htmlQ;
it.cuerpo.innerHTML = it.htmlA;
const todo = it.nq + " " + it.na;
const ok = terms.every((t) => todo.includes(t));
it.raiz.hidden = !ok;
it.tag.hidden = !terms.length || !ok || terms.some((t) => it.nq.includes(t));
if (!ok) return;
n++;
if (terms.length) { marcar(it.q, terms); marcar(it.cuerpo, terms); }
if (antes && !reducido.matches) it.raiz.animate([{ opacity: 0, transform: "translateY(8px)" }, { opacity: 1, transform: "none" }], { duration: 320, easing: "cubic-bezier(.16,1,.3,1)" });
});
vacio.hidden = n > 0;
vacio.querySelector(".vacio__q").textContent = bruto;
estado.textContent = !terms.length ? items.length + " preguntas"
: n ? n + (n === 1 ? " resultado" : " resultados") + " para «" + bruto + "»"
: "Sin resultados para «" + bruto + "»";
actualizarTodas();
}
input.addEventListener("input", () => { clearTimeout(espera); espera = setTimeout(filtrar, 120); });
input.addEventListener("keydown", (e) => { if (e.key === "Escape" && input.value) { e.preventDefault(); input.value = ""; filtrar(); } });
const limpiar = () => { input.value = ""; filtrar(); input.focus(); };
borrar.addEventListener("click", limpiar);
document.querySelector(".ver-todas").addEventListener("click", limpiar);
document.querySelectorAll(".chip").forEach((c) => c.addEventListener("click", () => { input.value = c.dataset.q; filtrar(); input.focus(); }));
document.addEventListener("keydown", (e) => {
const escribiendo = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement.tagName);
if (e.key === "/" && !escribiendo && !e.ctrlKey && !e.metaKey) { e.preventDefault(); input.focus(); }
});
// JSON-LD FAQPage generado desde el mismo contenido
const datos = {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: items.map((it) => ({
"@type": "Question",
name: it.textoQ,
acceptedAnswer: { "@type": "Answer", text: it.textoA }
}))
};
const script = document.createElement("script");
script.type = "application/ld+json";
script.textContent = JSON.stringify(datos);
document.head.append(script);
const json = JSON.stringify(datos, null, 2);
document.querySelector(".codigo code").innerHTML = json
.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c]))
.replace(/("(?:[^"\\]|\\.)*")(\s*:)?/g, (m, s, dos) => dos ? '<span class="k">' + s + "</span>" + dos : '<span class="s">' + s + "</span>");
const copiar = document.querySelector(".copiar");
copiar.addEventListener("click", async () => {
try { await navigator.clipboard.writeText(json); copiar.textContent = "Copiado"; }
catch (e) { getSelection().selectAllChildren(document.querySelector(".codigo code")); copiar.textContent = "Selecciona y copia"; }
setTimeout(() => { copiar.textContent = "Copiar"; }, 1800);
});
// Entrada y primera pregunta abierta (o la del enlace #ancla)
const porAncla = () => items.find((it) => "#" + it.raiz.id === location.hash);
addEventListener("hashchange", () => { const it = porAncla(); if (it) alternar(it, true); });
const primera = porAncla() || items[0];
if (reducido.matches) { alternar(primera, true); return; }
const entrada = [...document.querySelectorAll(".intro > *, .cabecera, .acordeon > li, .ld")];
entrada.forEach((el, i) => el.animate(
[{ opacity: 0, transform: "translateY(18px)" }, { opacity: 1, transform: "none" }],
{ duration: 900, delay: 80 + i * 45, easing: "cubic-bezier(.16,1,.3,1)", fill: "backwards" }));
setTimeout(() => alternar(primera, true), 750);
})();
```
Por qué está hecho así (no lo cambies):
- **`muelle()`** integra con Euler semiimplícito a 240 Hz (4 subpasos por fotograma) y guarda una muestra por fotograma a 60 fps. Para cuando la distancia al destino y la velocidad caen por debajo de un umbral relativo (`0.25 %` del recorrido), con tope de 3 s. La última muestra se fuerza al valor exacto de destino.
- **`Resorte`** convierte esas muestras en fotogramas clave para `el.animate(…, { easing: "linear" })`. Antes de animar deja **escrito el estado final** en el estilo en línea, así que al terminar o cancelar no hay parpadeo. `actual()` interpola la posición y lee la velocidad de la muestra en curso a partir de `anim.currentTime`: con eso, cualquier interrupción arranca un muelle nuevo con la velocidad heredada.
- Al **abrir**: se quita `hidden`, se mide `.cuerpo.offsetHeight` (el hijo tiene su altura natural aunque el panel mida 0) y se anima la altura; al acabar se borra `height` para que vuelva a `auto` y acompañe a cualquier cambio de ancho. Al **cerrar**: si no había animación, se parte de `offsetHeight` real; al acabar se pone `hidden`.
- La **tensión** vive en un `div.tension` que el JS inserta tras cada `h3`. Al abrir, su altura actual se pasa como punto de partida del panel y la tensión se fija a 0: el estirón se convierte en apertura sin escalón.
- **Buscador**: `normaliza()` pasa carácter a carácter por NFD y quita las marcas diacríticas, de forma que la cadena normalizada tiene **la misma longitud** que la original y los índices sirven para cortar el texto real al envolver `<mark>`. Antes de cada búsqueda se restaura el HTML original de pregunta y respuesta (guardado al arrancar). Espera de 120 ms tras teclear.
- **JSON-LD**: se construye una sola vez con los textos originales (sin `<mark>`), se inserta en el `<head>` y se muestra formateado con `JSON.stringify(…, null, 2)`, coloreando claves (`.k`) y cadenas (`.s`) con una expresión regular tras escapar `& < >`.
## Interacción
- **Clic / Enter / Espacio** en una pregunta: abre o cierra (varias pueden estar abiertas a la vez). Actualiza `aria-expanded`, la clase `.abierto` del `li`, el icono y el cabo.
- **Teclado en la lista**: ↓ y ↑ mueven el foco a la pregunta visible siguiente/anterior (en bucle), Inicio y Fin a la primera y la última.
- **«Abrir todas» / «Cerrar todas»**: actúa solo sobre las preguntas visibles (respeta el filtro), en cascada de 45 ms; su texto cambia según estén todas abiertas o no, y se oculta si no hay resultados.
- **Buscador**: escribe y filtra; `/` enfoca el campo desde cualquier sitio (salvo si ya se escribe en un campo); `Esc` lo vacía; el botón redondo de tinta con × aparece cuando hay texto y lo vacía; el atajo `kbd /` se oculta cuando hay texto. Las píldoras «fianza», «patrón», «perro», «cancelar», «niños» rellenan el campo y filtran.
- **Estado** (`#faq-estado`, `aria-live="polite"`): «10 preguntas» · «N resultado(s) para «texto»» · «Sin resultados para «texto»».
- **Estado vacío**: «Nada sobre «texto».» + contacto + botón «Ver todas las preguntas» que limpia la búsqueda y devuelve el foco al campo.
- **Anclas**: si la URL trae `#fianza` (o cualquier `id` de `li`), esa pregunta es la que se abre al cargar; `hashchange` abre la nueva.
- **Hover** (solo `hover: hover` y `pointer: fine`): la pregunta se desplaza 8 px, el círculo crece a 1.08 y marca su borde en tinta, la fila se estira 8 px; las píldoras se rellenan de tinta; las flechas de los enlaces de contacto avanzan 4 px.
- **Táctil**: todo funciona con toque; no hay nada que dependa del hover (la tensión solo responde a `pointerType === "mouse"`).
- **Copiar** el JSON-LD: `navigator.clipboard.writeText`; si el iframe lo bloquea, selecciona el código y el botón dice «Selecciona y copia». Vuelve a «Copiar» a los 1,8 s.
## Adaptación a móvil y tablet
- **≤ 980 px**: una sola columna; la introducción deja de ser pegajosa; se ocultan las coordenadas; hueco entre bloques 56 px.
- **≤ 600 px**: barra de metadatos a 10,5 px y sin « · abril–octubre»; titular `clamp(42px, 12.5vw, 60px)`; filas con rejilla `34px · 1fr · auto` y padding 22 px; icono de 40 px; respuesta con padding `0 0 26px 44px` y 16 px; las tablas `dl.datos` pasan a una columna con etiqueta a la izquierda y cifra a la derecha; contacto en una columna sin sangría; estado vacío sin sangría; se oculta el `kbd`.
- El `kbd /` también se oculta en dispositivos sin hover.
- Sin scroll horizontal de 360 a 1920 px (`overflow-x: hidden` en `body` y `minmax(0, …)` en todas las columnas). Usa `100svh`, nunca `100vh`.
## Accesibilidad y movimiento reducido
- Cada pregunta es un `button` dentro de un `h3`, con `aria-expanded` y `aria-controls` apuntando a su panel. Número e icono llevan `aria-hidden`. Los paneles ocultos usan el atributo `hidden` (fuera del árbol de accesibilidad y del orden de tabulación).
- El buscador tiene `label` visible, `role="search"` en el contenedor, `aria-describedby="faq-estado"` y un botón de borrar con `aria-label="Borrar la búsqueda"`.
- Foco visible: `outline: 2px solid var(--acento)` con `outline-offset: 3px`.
- Contraste: tinta sobre papel 13,8:1; tinta suave 6,8:1; acento 4,7:1.
- El cabo SVG y la `div.tension` son decorativos (`aria-hidden`). El `pre` del código es enfocable (`tabindex="0"`) para poder desplazarlo con teclado.
- **`prefers-reduced-motion: reduce`**: no hay entrada ni cascadas; `Resorte.ir` aplica el valor final al instante; el cabo no vibra; la tensión no se mueve; las transiciones CSS duran .01 ms y la pregunta no se desplaza en hover. La primera pregunta se abre al cargar sin animación.
## Rendimiento
- Sin WebGL ni bucles permanentes: `rendimiento` = DPR no aplica, sin fallback necesario.
- Las alturas se animan con WAAPI (fotogramas precalculados); el único `requestAnimationFrame` es el del cabo y **se detiene solo** a los 2,2 s de cada pulso.
- Los muelles se integran una vez por interacción (≤ 180 muestras) y se descartan.
- La búsqueda espera 120 ms tras la última tecla antes de filtrar.
- Peso total del HTML ≈ 38 KB.
## Personalización
- **Marca y datos**: Noray, Brenmar, +00 000 000 000 y hola@noray.com aparecen en la barra superior, el `<title>`, la descripción, el contacto y el estado vacío. Cambia también las coordenadas de la barra por las de tu local.
- **Colores**: `--papel` (#f3eee4), `--tinta` (#11242f) y `--acento` (#b8431b) en `:root`. Filetes, resaltado y anillo de foco se derivan solos con `color-mix`. Si el acento es claro, oscurécelo hasta 4,5:1 sobre el fondo: se usa en texto pequeño.
- **Preguntas**: añade, quita o reordena `li.item` siguiendo el patrón (ids `SLUG`, `q-SLUG`, `r-SLUG` únicos). El contador, el buscador, «Abrir todas» y el JSON-LD se actualizan solos porque leen el DOM. Puedes usar `strong`, enlaces y `dl.datos` dentro de las respuestas.
- **Sensación del muelle**: en `MUELLE`, sube `c` para menos rebote (≈ 26 es casi crítico con k 170) o baja `k` para una apertura más lenta.
- **Sugerencias**: cambia los `data-q` y el texto de las píldoras por las dudas más buscadas de tu negocio.
- **Cabo**: si el sector no es náutico, puede sustituirse por un filete recto; basta con borrar el `svg.cabo` (el JS no falla si no existe, porque `querySelectorAll` devuelve una lista vacía).
## Comprobación final
1. Al cargar, los bloques entran en cascada y a los 750 ms la pregunta 01 se abre con un rebote visible y se asienta; el cabo vibra y se detiene.
2. Clic en «¿Qué incluye el precio?»: la altura sobrepasa y vuelve; aparece la tabla de tres celdas con 390 €, 690 € y 290 € (IVA incl.).
3. Doble clic rápido en una pregunta: la animación se invierte desde donde estaba, sin saltos.
4. Con ratón, pasar por encima de una fila cerrada la estira 8 px con rebote; al hacer clic se abre sin escalón.
5. El + se convierte en − girando con muelle y el círculo se rellena de tinta; el filete superior de la fila abierta se pinta de acento.
6. Buscar «ninos» (sin tilde) muestra solo la pregunta 09 con «niños» resaltado; buscar «perro» muestra la 07; «xyzzy» enseña el estado vacío y «Sin resultados para «xyzzy»».
7. Una coincidencia solo en la respuesta (p. ej. «Bizum») muestra la etiqueta «En la respuesta».
8. «Abrir todas» abre en cascada solo las visibles y pasa a «Cerrar todas».
9. ↓/↑/Inicio/Fin mueven el foco entre preguntas; `/` enfoca el buscador; `Esc` lo vacía.
10. En `<head>` existe un `script[type="application/ld+json"]` con `@type: "FAQPage"` y 10 `Question`; la tarjeta de datos estructurados lo muestra coloreado.
11. Con `prefers-reduced-motion: reduce`, todo se abre y cierra al instante y nada vibra.
12. A 360 px no hay scroll horizontal y las tablas de datos pasan a una columna.
13. Sin JS se leen todas las respuestas.
14. Consola sin errores.Prueba en frío
Pegamos el prompt en Claude Sonnet 5.5, sin ayuda ni contexto, y comparamos el resultado con la demo. Prueba hecha el 30-09-2026.

Calco prácticamente exacto: el HTML resultante coincide línea a línea con la demo (solo difiere una línea en blanco), así que maquetación, muelles, cabo que vibra, buscador y JSON-LD son idénticos en escritorio y móvil.

El núcleo sale igual: rejilla, titular, cabo, filas con muelle real (mismos k/c), tensión, buscador sin tildes y JSON-LD. Cambia el acabado: grano de papel más sucio y por encima del contenido, titular sin el eje óptico, respuestas resumidas, contacto convertido en tarjeta con píldoras, tarjeta JSON-LD con título invertido y sin muelle, y en móvil la barra superior se parte en dos líneas.
Solo publicamos lo que hemos probado. Otras herramientas (ChatGPT, Gemini, Lovable…) pueden dar resultados distintos.
Paso a paso
Personaliza
Copia el Completo o el Lite
Pega e itera
Publica
Licencia en claro
Todas las piezas incluyen licencia de uso comercial. Usa el resultado tal cual en tus productos, encargos freelance o webs de clientes, sin atribución.
Puedes
No puedes
Es la misma licencia para lo gratis y lo Pro. La tienes entera en licencia de uso.
Preguntas frecuentes
Versión 1.0 · publicada el 30-09-2026

Bloques de web · GSAP

Bloques de web · CSS

Bloques de web · CSS

Bloques de web · JS

Bloques de web · GSAP

Bloques de web · GSAP

Bloques de web · SVG

Bloques de web · GSAP