ScreenshotNeo

BlogHow-to

How to Load CSS from a String When Rendering HTML in Ruby

Learn how to apply CSS stored in a Ruby string for Rails responses, ERB, PDFs, images, and HTML parsing—without confusing rendering with parsing.

By the ScreenshotNeo team1 October 20268 min read

For a normal Rails HTML response, put the CSS string inside a <style> element in the document and return the resulting HTML. Use render html: for literal HTML, and render inline: only when the string contains ERB that must be evaluated. If you need a PDF or image, use a browser-backed renderer such as Grover and pass the CSS as inline style-tag content. Nokogiri can parse HTML, but it does not calculate CSS layout.

1. Return HTML with CSS embedded in a string

This is the smallest complete Rails example:

class ReportsController < ApplicationController
  def show
    css = <<~CSS
      body {
        font-family: system-ui, sans-serif;
        margin: 2rem;
      }

      .notice {
        color: #176b3a;
        font-weight: 700;
      }
    CSS

    html = <<~HTML
      <!doctype html>
      <html>
        <head>
          <meta charset="utf-8">
          <meta name="viewport" content="width=device-width, initial-scale=1">
          <style>
            #{css}
          </style>
        </head>
        <body>
          <p class="notice">Ready</p>
        </body>
      </html>
    HTML

    render html: html.html_safe
  end
end

render html: returns an HTML response. Rails escapes a string unless it is marked HTML safe, so the example marks the complete document safe only because the markup and CSS are trusted application code. Do not mark a string containing unescaped user input as safe.

Inline HTML responses do not use a layout by default. Enable one explicitly when required:

render html: html.html_safe, layout: true
# or
render html: html.html_safe, layout: "print"

Keep user data escaped

Build the document with trusted markup, while escaping dynamic values. Rails helpers are safer for user-provided text:

name = params[:name].to_s
safe_name = ERB::Util.html_escape(name)

html = <<~HTML
  <!doctype html>
  <html>
    <head><style>body { font-family: sans-serif; }</style></head>
    <body><h1>Hello, #{safe_name}</h1></body>
  </html>
HTML

render html: html.html_safe

If the document grows beyond a small response, a normal view template is easier to review and maintain. The string technique is useful for generated documents, small endpoints, and content assembled by a service object.

2. Decide whether you need render html: or render inline:

Input Use What happens
Literal HTML string render html: Returns the string as an HTML response after Rails safety handling.
String containing ERB tags render inline: Evaluates the string as an ERB template, then renders it.
CSS text Put it in <style> The browser applies the rules to the returned document.
CSS file or URL stylesheet_link_tag or a normal <link> Loads a separate stylesheet resource.

Use render inline: for ERB source

class WelcomeController < ApplicationController
  def show
    template = <<~ERB
      <!doctype html>
      <html>
        <head>
          <style>body { font-family: sans-serif; }</style>
        </head>
        <body>
          <h1>Hello, <%= @name %>!</h1>
        </body>
      </html>
    ERB

    @name = "Ruby"
    render inline: template
  end
end

render inline: is template evaluation, not a special CSS loader. Treat template source as trusted code. Never concatenate untrusted input into an ERB template and execute it.

3. Use a linked stylesheet when CSS is not really a string

For an asset-pipeline stylesheet, keep CSS in a file and reference it from a view:

<%= stylesheet_link_tag "reports", "data-turbo-track": "reload" %>

This helper generates a link to a stylesheet resource. It does not take arbitrary CSS text and convert it into a stylesheet. If your source is a string at runtime, use a <style> element instead.

4. Generate a PDF or image with CSS from a string

A Rails response only sends HTML. A PDF or image requires a renderer that runs a layout engine. Grover accepts inline HTML and style-tag options:

require "grover"

html = <<~HTML
  <!doctype html>
  <html>
    <body class="invoice">
      <h1>Invoice</h1>
      <p>Paid</p>
    </body>
  </html>
HTML

css = <<~CSS
  @page { size: A4; margin: 18mm; }
  body { font-family: Arial, sans-serif; color: #222; }
  .invoice { border: 1px solid #ddd; padding: 24px; }
CSS

pdf = Grover.new(
  html,
  style_tag_options: [{ content: css }],
  display_url: "https://example.com/"
).to_pdf

File.binwrite("invoice.pdf", pdf)

Grover uses Puppeteer and Chromium for PDF, PNG, and JPEG conversion. When HTML contains relative images, fonts, or stylesheets, provide a suitable display_url or rewrite those URLs as absolute paths. Without a display URL, relative paths resolve against a default host and commonly fail.

Return the generated PDF from Rails

send_data pdf,
  filename: "invoice.pdf",
  type: "application/pdf",
  disposition: "inline"

WickedPDF and other renderers

WickedPDF also supports creating a PDF from an HTML string. Its documented examples use absolute stylesheet paths and a stylesheet helper. Check the version installed in your application before copying options because renderer APIs and runtime dependencies vary.

5. Parse HTML with Nokogiri, but do not expect visual styling

Nokogiri is appropriate when you need to inspect or transform markup:

require "nokogiri"

document = Nokogiri::HTML5::Document.parse(<<~HTML)
  <html><body><p class="notice">Ready</p></body></html>
HTML

document.at_css(".notice").content = "Updated"
puts document.to_html

Parsing preserves and manipulates the document tree. It does not load fonts, calculate box sizes, execute browser layout, or apply CSS visually. Use Chromium through a renderer when the output must match a browser screenshot or PDF.

6. A reusable service for HTML plus CSS

Keeping assembly in one object makes escaping and renderer selection explicit:

class HtmlDocument
  def initialize(body_html:, css: "")
    @body_html = body_html
    @css = css
  end

  def to_html
    <<~HTML
      <!doctype html>
      <html>
        <head>
          <meta charset="utf-8">
          <style>#{@css}</style>
        </head>
        <body>#{@body_html}</body>
      </html>
    HTML
  end
end

css = "body { color: #333; }"
body = "<p>Generated document</p>"
html = HtmlDocument.new(body_html: body, css: css).to_html

render html: html.html_safe

Only pass trusted, already-sanitized markup as body_html. If the body is text, escape it before interpolation. For complex documents, use Rails views and helpers instead of assembling raw markup.

7. Troubleshooting

Symptom Likely cause Fix
The page shows literal HTML tags The response was escaped. Use render html: with trusted complete markup, or build the response with Rails helpers. Do not mark untrusted input safe.
CSS appears as text The CSS was placed in the body without a style element. Wrap it in <style>...</style> inside <head>.
ERB tags remain visible You used render html: for an ERB source string. Use render inline:, or move the markup into a view.
Variables are not available The inline template does not have the expected controller instance variables. Assign them before render inline:, or use a normal view.
The layout is missing Inline HTML rendering does not select a layout by default. Pass layout: true or a named layout.
PDF has no colors or spacing The CSS was never passed to the document renderer, or unsupported browser CSS was used. Pass style_tag_options: [{ content: css }] and verify the renderer’s supported CSS.
Images or fonts disappear in a PDF Relative URLs cannot be resolved by Chromium. Set display_url or use absolute URLs and ensure the renderer can reach them.
HTML parses but does not look styled Nokogiri is a parser, not a layout engine. Use a browser-backed PDF or image renderer for visual output.
HTML injection or script execution is possible User input was interpolated into trusted markup or an ERB template. Escape text, sanitize allowed HTML, and never execute untrusted ERB source.

8. Performance, reliability, and cost considerations

  • HTTP HTML responses: assembling one string is usually inexpensive, but large documents increase memory use and response size. Prefer templates for maintainability and streaming or pagination for very large output.
  • PDF and image rendering: launching or coordinating Chromium is substantially heavier than returning HTML. Reuse a managed browser where your renderer supports it, limit concurrent jobs, and set request timeouts.
  • External assets: network fonts, images, and stylesheets make output dependent on DNS, TLS, authentication, and remote availability. Bundle critical CSS and use stable absolute URLs when deterministic output matters.
  • Repeatability: specify page size, margins, viewport, fonts, and timezone when those values affect layout. Record renderer and browser versions when output is part of a test or legal document.
  • Security: avoid allowing arbitrary URLs or CSS from users. Server-side renderers can become a path to internal network access if they fetch attacker-controlled URLs.

9. Or skip the browser setup

If your goal is a PNG, JPEG, WebP, or PDF rather than an HTML response, ScreenshotNeo provides a website screenshot API. Send one GET request with the URL; its capture options include custom CSS and JavaScript, full-page or selector captures, device and viewport settings, PDF output, waiting rules, headers, cookies, blocking controls, caching, asynchronous jobs, bulk capture, and signed links. The ScreenshotNeo docs list the request parameters.

curl -G "https://api.screenshotneo.com/v1/shot" \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://stripe.com \
  -o shot.webp
import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const bytes = Buffer.from(await res.arrayBuffer());
require('fs').writeFileSync('shot.webp', bytes);

Cookie banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing result. An MCP server lets AI agents use take_screenshot, get_page_info, and capture_pdf. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000.

Create a free ScreenshotNeo account to try the API.

10. FAQ

Can I pass CSS directly to render html:?

No special CSS argument is needed. Embed the text in a <style> element in the HTML string.

Does html_safe sanitize CSS or HTML?

No. It tells Rails to trust the string. Use it only for markup you constructed safely.

Should I use Nokogiri to create screenshots?

No. Nokogiri parses and edits markup; use a browser-backed renderer for visual layout.

Why does my inline template not use the application layout?

Inline rendering omits layouts by default. Pass a layout option or render a normal view.

What is the simplest choice for a PDF with runtime CSS?

Use a Chromium-backed renderer such as Grover and provide the CSS through its inline style-tag option.