Biscuit
GDPR-compliant cookie consent banner for Rails 8. Renders a configurable bottom/top banner, manages consent state via a browser cookie, and exposes a Stimulus controller for interactivity. Supports i18n and CSS custom property theming with no external runtime dependencies.
Project page · GitHub · Changelog · Issues
Requirements
| Requirement | Version |
|---|---|
| Ruby | >= 3.2 |
| Rails | >= 8.0 |
| Stimulus | Any (via @hotwired/stimulus) |
| Import maps | Rails default (importmap-rails) |
No Sprockets, no build step. Assets are served via Propshaft (Rails 8 default).
AI-assisted setup (Claude Code)
If you use Claude Code, the quickest way to install and configure biscuit is via the built-in setup skill.
After adding the gem to your Gemfile and running bundle install, run the
generator to install the skill:
rails generate biscuit:install
Then open Claude Code in your project and run:
/biscuit-install
The skill will:
- Check your app is compatible (Ruby, Rails, Propshaft, Stimulus)
- Mount the engine and register the Stimulus controller
- Ask about banner position, cookie categories, and other preferences
- Generate
config/initializers/biscuit.rbfrom your answers - Scan your codebase for existing cookies and third-party tracking scripts
(Google Analytics, GTM, Meta Pixel, etc.) and help you wrap them with
biscuit_allowed?guards - Add integration tests and run them
- Optionally commit everything
Manual installation
Add to your Gemfile:
gem "biscuit-rails"
Then:
bundle install
Setup
1. Mount the engine
In config/routes.rb:
Rails.application.routes.draw do
mount Biscuit::Engine, at: "/biscuit"
# ... your other routes
end
2. Register the Stimulus controller
In app/javascript/controllers/index.js:
import BiscuitController from "biscuit/biscuit_controller"
application.register("biscuit", BiscuitController)
3. Include the stylesheet
In your layout (app/views/layouts/application.html.erb):
<%= stylesheet_link_tag "biscuit/biscuit" %>
4. Render the banner
In your layout, inside <body>:
<%= biscuit_banner %>
That's it. The banner renders on every page. Once a user makes a consent choice it hides itself and shows a small "Cookie settings" link so they can revisit their preferences at any time.
Banner Options
biscuit_banner accepts keyword options to control behaviour per-page.
Currently there are no banner-level options. Configuration is done via the initializer — see Configuration below.
biscuit:consent-given event
After any consent POST succeeds, the controller dispatches a
biscuit:consent-given DOM event on the banner's root element. The event
bubbles, so you can listen anywhere:
document.addEventListener("biscuit:consent-given", ({ detail }) => {
if (detail.categories.analytics) {
// GA4 Consent Mode v2 — no page reload needed
gtag("consent", "update", { analytics_storage: "granted" })
gtag("event", "page_view")
}
if (detail.categories.marketing) {
fbq("consent", "grant")
fbq("track", "PageView")
}
})
detail.categories mirrors the payload sent to the server —
{ analytics: true, marketing: false, ... }.
If you need a full page reload after consent (for example, to trigger
server-side biscuit_allowed?() guards on the current page), do it in the
handler:
document.addEventListener("biscuit:consent-given", () => {
window.location.reload()
})
Configuration
Create an initializer at config/initializers/biscuit.rb:
Biscuit.configure do |config|
# Cookie categories — see "Custom categories" below
config.categories = {
necessary: { required: true },
analytics: { required: false },
preferences: { required: false },
marketing: { required: false }
}
# Name of the browser cookie that stores consent state
# Default: "biscuit_consent"
config. = "biscuit_consent"
# How long the consent cookie lasts, in days
# Default: 365
config. = 365
# Cookie path
# Default: "/"
config. = "/"
# Cookie domain — nil means current domain
# Default: nil
config. = nil
# SameSite attribute
# Default: "Lax"
config. = "Lax"
# Banner position: :bottom or :top
# Default: :bottom
config.position = :bottom
# URL for the "Learn more" privacy policy link
# Default: "#"
config.privacy_policy_url = "/privacy"
end
All options at a glance
| Option | Default | Description |
|---|---|---|
categories |
{necessary: {required: true}, analytics: {required: false}, marketing: {required: false}} |
Cookie categories shown to the user |
cookie_name |
"biscuit_consent" |
Browser cookie name |
cookie_expires_days |
365 |
Cookie lifetime in days |
cookie_path |
"/" |
Cookie path |
cookie_domain |
nil |
Cookie domain (nil = current domain) |
cookie_same_site |
"Lax" |
SameSite cookie attribute |
position |
:bottom |
Banner position (:bottom or :top) |
privacy_policy_url |
"#" |
"Learn more" link URL |
Custom Cookie Categories
Define any categories you need. Each entry requires a :required key.
Categories with required: true are shown as permanently checked and
non-toggleable (necessary cookies). All others are opt-in checkboxes.
config.categories = {
necessary: { required: true },
analytics: { required: false },
preferences: { required: false },
marketing: { required: false }
}
Add matching i18n keys for each custom category. For example, to add a
preferences category, add to config/locales/en.yml:
en:
biscuit:
categories:
preferences:
name: "Preferences"
description: "Remember your settings and personalisation choices."
Biscuit ships with built-in translations for necessary, analytics,
marketing, and preferences in English and French. Any other category
requires you to add your own keys.
CSS Theming
All styles are scoped under .biscuit-banner. Every visual property is
expressed as a CSS custom property, so you can override the entire look
without touching the gem.
Available custom properties
| Property | Default | Description |
|---|---|---|
--biscuit-bg |
Canvas |
Banner background colour (browser default background) |
--biscuit-color |
CanvasText |
Banner text colour (browser default text) |
--biscuit-muted |
GrayText |
Secondary / description text colour |
--biscuit-accent |
#4f46e5 |
Primary button background |
--biscuit-accent-hover |
#4338ca |
Primary button hover background |
--biscuit-border |
rgba(0,0,0,0.12) |
Divider / border colour |
--biscuit-radius |
0.375rem |
Button / panel border radius |
--biscuit-font-size |
0.875rem |
Base font size |
--biscuit-font-family |
inherit |
Font family |
--biscuit-z-index |
9999 |
Stack order |
--biscuit-padding |
1rem 1.5rem |
Banner padding |
--biscuit-shadow-bottom |
0 -2px 12px rgba(0,0,0,0.12) |
Shadow when position: bottom |
--biscuit-shadow-top |
0 2px 12px rgba(0,0,0,0.12) |
Shadow when position: top |
--biscuit-max-width |
64rem |
Inner content max-width |
Override example
In your application's CSS, after including the biscuit stylesheet:
.biscuit-banner {
--biscuit-accent: #0070f3;
--biscuit-accent-hover: #005bb5;
--biscuit-border: rgba(0, 0, 0, 0.08);
}
Checking Consent in Views
Use the biscuit_allowed? helper, which is available in all views and
layouts:
<% if biscuit_allowed?(:analytics) %>
<!-- Google Analytics or similar -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXX"></script>
<% end %>
<% if biscuit_allowed?(:marketing) %>
<!-- Marketing pixel -->
<% end %>
:necessary always returns true regardless of cookie state.
Checking Consent in Controllers
class ApplicationController < ActionController::Base
def analytics_enabled?
Biscuit::Consent.new().allowed?(:analytics)
end
end
Cookie Format
The consent cookie stores a JSON payload:
{
"v": 1,
"consented_at": "2026-03-19T10:00:00Z",
"categories": {
"necessary": true,
"analytics": false,
"marketing": true
}
}
v— schema version (currently1). Biscuit ignores cookies from unknown versions.consented_at— UTC ISO 8601 timestamp of when consent was recorded.categories— per-category boolean map.necessaryis alwaystrue.
The cookie is not httponly so that client-side JavaScript can read
consent state for lazy-loading scripts.
GDPR Notes
Biscuit provides the consent UI and storage mechanism. You are responsible for:
What Biscuit does
- Renders a banner that requires an explicit user action before dismissal (no auto-dismiss)
- Offers equally prominent "Accept all" and "Reject non-essential" buttons
- Records granular, timestamped consent per category
- Allows the user to withdraw or amend consent at any time via the "Cookie settings" link
- Marks
:necessarycookies as non-toggleable and clearly labelled - Writes no non-essential cookies itself — only the consent cookie, which is a functional/necessary cookie
What Biscuit does NOT do
- It does not block third-party scripts automatically. You must
conditionally load scripts based on
biscuit_allowed?(:category). See the pattern below. - It does not implement geo-targeting (showing the banner only to EU visitors).
- It does not store consent in a database (v1 is cookie-only).
- It does not provide a legal opinion on whether your implementation meets GDPR requirements. Consult a lawyer.
Pattern: blocking non-essential scripts until consent
<%# In your layout — only load analytics after consent %>
<% if biscuit_allowed?(:analytics) %>
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXX"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXX');
</script>
<% end %>
For scripts that must load on the client side after a user accepts consent
during their current session (without a page reload), listen for the
biscuit:consent-given DOM event and initialise scripts there.
GDPR compliance checklist
- [x] No non-essential cookies set before consent
- [x] Consent is freely given — equal prominence for accept and reject
- [x] No pre-ticked boxes for non-required categories
- [x] No dark patterns
- [x] User can withdraw or amend consent at any time
- [x] Consent is granular — recorded per category
- [x] Consent is timestamped
- [x] Necessary cookies are clearly labelled and non-toggleable
- [x] Banner does not auto-dismiss
i18n
Biscuit ships with translations for English (en), French (fr),
German (de), and Spanish (es). To add another locale, create
config/locales/biscuit.<locale>.yml in your app:
pt:
biscuit:
banner:
aria_label: "Consentimento de cookies"
message: "Utilizamos cookies para melhorar a sua experiência neste site."
learn_more: "Saber mais"
accept_all: "Aceitar tudo"
reject_all: "Rejeitar não essenciais"
manage: "Gerir preferências"
save: "Guardar preferências"
reopen: "Definições de cookies"
categories:
necessary:
name: "Necessários"
description: "Indispensáveis para o funcionamento do site. Não podem ser desativados."
analytics:
name: "Análise"
description: "Ajudam-nos a perceber como os visitantes utilizam o site."
marketing:
name: "Marketing"
description: "Utilizados para mostrar publicidade personalizada."
preferences:
name: "Preferências"
description: "Memorizam as suas definições e escolhas de personalização."
Engine Routes
The engine mounts two endpoints:
| Method | Path | Action |
|---|---|---|
POST |
/biscuit/consent |
Record consent for all categories |
DELETE |
/biscuit/consent |
Clear the consent cookie |
Both endpoints require a valid CSRF token. The Stimulus controller
reads the token from the data-biscuit-csrf-token-value attribute
(set automatically by the banner partial from form_authenticity_token).
License
MIT